mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
Preserve composer hyperlinks across wrapped lines (#40720)
## What changed - Detect visible HTTP(S) URLs in the composer and attach the complete OSC 8 destination to every wrapped fragment, including when part of the URL is scrolled offscreen. - Cache hyperlink metadata with the textarea wrap state while leaving masked input and destinations larger than 8 KiB unannotated. - Make shared hyperlink mapping grapheme-aware, recognize Unicode whitespace boundaries, and scan URLs and trailing delimiters incrementally for large inputs. ## Testing - Add coverage for wrapped and scrolled URLs, joined emoji, Unicode whitespace, preserved highlighting, masked input, cache invalidation, oversized destinations, and drafts containing many URLs. GitOrigin-RevId: 40facdf2fe79438c91c82bff2d849c342f1ab28a
This commit is contained in:
1 parent
42624fd63b
commit
d6174a8798
9 files changed
+726
-50
No files matched your search
@@ -13,6 +13,8 @@
|
||||
//! Wrapping also reserves a visible insertion point: full logical lines get continuation rows,
|
||||
//! and trailing spaces wrap instead of moving the cursor outside the textarea. At soft word
|
||||
//! breaks, interior separators hang off the preceding row without changing the editable text.
|
||||
//! Visible web URLs carry their complete terminal hyperlink destination across wrapped rows;
|
||||
//! masked rendering never exposes hyperlink destinations.
|
||||
|
||||
use crate::key_hint::KeyBindingListExt;
|
||||
use crate::key_hint::is_altgr;
|
||||
@@ -38,12 +40,14 @@ use ratatui::text::Span;
|
||||
use ratatui::widgets::StatefulWidgetRef;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use std::borrow::Cow;
|
||||
use std::cell::OnceCell;
|
||||
use std::cell::Ref;
|
||||
use std::cell::RefCell;
|
||||
use std::ops::Range;
|
||||
use std::sync::Arc;
|
||||
use unicode_segmentation::UnicodeSegmentation;
|
||||
|
||||
mod hyperlinks;
|
||||
mod vim;
|
||||
mod vim_commands;
|
||||
mod wrapping;
|
||||
@@ -147,6 +151,7 @@ pub(crate) struct TextArea {
|
||||
struct WrapCache {
|
||||
width: u16,
|
||||
lines: Vec<Range<usize>>,
|
||||
hyperlinks: OnceCell<hyperlinks::HyperlinkCache>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
@@ -1994,7 +1999,11 @@ impl TextArea {
|
||||
if needs_recalc {
|
||||
let display_text = text_for_display(&self.text);
|
||||
let lines = wrapping::wrapped_lines(display_text.as_ref(), width);
|
||||
*cache = Some(WrapCache { width, lines });
|
||||
*cache = Some(WrapCache {
|
||||
width,
|
||||
lines,
|
||||
hyperlinks: OnceCell::new(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2107,7 +2116,7 @@ impl TextArea {
|
||||
highlights: &[(Range<usize>, Style)],
|
||||
) {
|
||||
let element_style = base_style.fg(Color::Cyan);
|
||||
for (row, idx) in range.enumerate() {
|
||||
for (row, idx) in range.clone().enumerate() {
|
||||
let r = &lines[idx];
|
||||
let y = area.y + row as u16;
|
||||
let visible = wrapping::visible_prefix(&self.text[r.start..r.end - 1], area.width);
|
||||
@@ -2149,6 +2158,12 @@ impl TextArea {
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(wrap_cache) = self.wrap_cache.borrow().as_ref() {
|
||||
wrap_cache
|
||||
.hyperlinks
|
||||
.get_or_init(|| hyperlinks::HyperlinkCache::new(&self.text, lines))
|
||||
.mark(buf, area, &self.text, lines, range);
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders width-preserving mask glyphs without writing outside the textarea viewport.
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
//! Preserve complete web destinations on visible, independently wrapped textarea rows.
|
||||
|
||||
use super::text_for_display;
|
||||
use super::wrapping::visible_prefix;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use crate::terminal_hyperlinks::TerminalHyperlink;
|
||||
use crate::terminal_hyperlinks::mark_buffer_hyperlinks;
|
||||
use crate::terminal_hyperlinks::web_links_in_text;
|
||||
use crate::width::display_width;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::text::Line;
|
||||
use std::ops::Range;
|
||||
|
||||
/// Destinations and row offsets retained for one lazily initialized textarea wrap state.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub(super) struct HyperlinkCache {
|
||||
hyperlinks: Vec<TerminalHyperlink>,
|
||||
source_columns: Vec<usize>,
|
||||
}
|
||||
|
||||
impl HyperlinkCache {
|
||||
/// Scan the draft once and precompute row offsets for constant-time scrolled lookup.
|
||||
pub(super) fn new(text: &str, lines: &[Range<usize>]) -> Self {
|
||||
let display_text = text_for_display(text);
|
||||
let hyperlinks = web_links_in_text(display_text.as_ref());
|
||||
if hyperlinks.is_empty() {
|
||||
return Self::default();
|
||||
}
|
||||
|
||||
let mut source_column = 0;
|
||||
let mut previous_start = 0;
|
||||
let source_columns = lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
source_column += display_width(&display_text[previous_start..line.start]);
|
||||
previous_start = line.start;
|
||||
source_column
|
||||
})
|
||||
.collect();
|
||||
|
||||
Self {
|
||||
hyperlinks,
|
||||
source_columns,
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach complete destinations after styling without revisiting offscreen draft text.
|
||||
pub(super) fn mark(
|
||||
&self,
|
||||
buf: &mut Buffer,
|
||||
area: Rect,
|
||||
text: &str,
|
||||
lines: &[Range<usize>],
|
||||
visible_rows: Range<usize>,
|
||||
) {
|
||||
if area.width == 0 || area.height == 0 || self.hyperlinks.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
for (row, index) in visible_rows.enumerate() {
|
||||
let line_range = &lines[index];
|
||||
let source_column = self.source_columns[index];
|
||||
let raw_visible = visible_prefix(
|
||||
&text[line_range.start..line_range.end.saturating_sub(1)],
|
||||
area.width,
|
||||
);
|
||||
let visible = text_for_display(raw_visible);
|
||||
let line_end = source_column + display_width(visible.as_ref());
|
||||
let first_hyperlink = self
|
||||
.hyperlinks
|
||||
.partition_point(|hyperlink| hyperlink.columns.end <= source_column);
|
||||
|
||||
let mut annotated = HyperlinkLine::new(Line::from(visible.into_owned()));
|
||||
for hyperlink in self.hyperlinks[first_hyperlink..]
|
||||
.iter()
|
||||
.take_while(|hyperlink| hyperlink.columns.start < line_end)
|
||||
{
|
||||
let start = hyperlink.columns.start.max(source_column) - source_column;
|
||||
let end = hyperlink.columns.end.min(line_end) - source_column;
|
||||
annotated.hyperlinks.push(TerminalHyperlink::web(
|
||||
start..end,
|
||||
hyperlink.destination.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
if !annotated.hyperlinks.is_empty() {
|
||||
let row_area =
|
||||
Rect::new(area.x, area.y + row as u16, area.width, /*height*/ 1);
|
||||
mark_buffer_hyperlinks(buf, row_area, &[annotated], /*scroll_rows*/ 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "hyperlinks_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,354 @@
|
||||
use super::super::TextArea;
|
||||
use super::super::TextAreaState;
|
||||
use crate::app_event::AppEvent;
|
||||
use crate::app_event_sender::AppEventSender;
|
||||
use crate::bottom_pane::ChatComposer;
|
||||
use crate::render::renderable::Renderable;
|
||||
use crate::terminal_hyperlinks::strip_osc8;
|
||||
use crate::width::display_width;
|
||||
use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::widgets::StatefulWidgetRef;
|
||||
use tokio::sync::mpsc::unbounded_channel;
|
||||
|
||||
type LinkedCell = (u16, u16, String, String);
|
||||
|
||||
fn linked_cells(buf: &Buffer) -> Vec<LinkedCell> {
|
||||
buf.area
|
||||
.positions()
|
||||
.filter_map(|position| {
|
||||
let symbol = buf[position].symbol();
|
||||
let (destination, _) = symbol.strip_prefix("\x1b]8;;")?.split_once('\x07')?;
|
||||
Some((
|
||||
position.x,
|
||||
position.y,
|
||||
strip_osc8(symbol),
|
||||
destination.to_string(),
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render(text: &str, width: u16, height: u16) -> (TextArea, TextAreaState, Buffer) {
|
||||
let mut textarea = TextArea::new();
|
||||
textarea.insert_str(text);
|
||||
let area = Rect::new(/*x*/ 0, /*y*/ 0, width, height);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let mut state = TextAreaState::default();
|
||||
StatefulWidgetRef::render_ref(&(&textarea), area, &mut buf, &mut state);
|
||||
(textarea, state, buf)
|
||||
}
|
||||
|
||||
fn linked_text(cells: &[LinkedCell]) -> String {
|
||||
cells.iter().map(|(_, _, text, _)| text.as_str()).collect()
|
||||
}
|
||||
|
||||
fn hyperlink_snapshot(buf: &Buffer) -> String {
|
||||
let mut snapshot = (0..buf.area.height)
|
||||
.map(|row| {
|
||||
let text = (0..buf.area.width)
|
||||
.map(|column| strip_osc8(buf[(column, row)].symbol()))
|
||||
.collect::<String>();
|
||||
format!("|{text}|\n")
|
||||
})
|
||||
.collect::<String>();
|
||||
let linked = linked_cells(buf);
|
||||
snapshot.push_str("Hyperlinks:\n");
|
||||
if linked.is_empty() {
|
||||
snapshot.push_str(" none\n");
|
||||
}
|
||||
for cells in linked.chunk_by(|left, right| {
|
||||
left.1 == right.1
|
||||
&& left.3 == right.3
|
||||
&& usize::from(left.0) + display_width(&left.2) == usize::from(right.0)
|
||||
}) {
|
||||
let (column, row, _, destination) = &cells[0];
|
||||
let last = cells.last().unwrap();
|
||||
let end = usize::from(last.0) + display_width(&last.2);
|
||||
snapshot.push_str(&format!(
|
||||
" row {row}, columns {column}..{end} -> {destination}\n"
|
||||
));
|
||||
}
|
||||
snapshot
|
||||
}
|
||||
|
||||
fn assert_destination(buf: &Buffer, destination: &str) -> Vec<LinkedCell> {
|
||||
let linked = linked_cells(buf);
|
||||
assert!(!linked.is_empty());
|
||||
assert!(linked.iter().all(|(_, _, _, target)| target == destination));
|
||||
linked
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrapped_url_fragments_keep_the_complete_destination() {
|
||||
let url = "https://github.com/openai/codex/pull/20252";
|
||||
let (_, _, buf) = render(
|
||||
&format!("Fix CI on {url}"),
|
||||
/*width*/ 26,
|
||||
/*height*/ 5,
|
||||
);
|
||||
let area = buf.area;
|
||||
let linked = assert_destination(&buf, url);
|
||||
assert_eq!(linked_text(&linked), url);
|
||||
assert!(linked.windows(2).any(|pair| pair[0].1 != pair[1].1));
|
||||
assert!(!buf[(0, 0)].symbol().contains("\x1b]8;;"));
|
||||
|
||||
let visible = (0..area.height)
|
||||
.map(|row| {
|
||||
(0..area.width)
|
||||
.map(|column| strip_osc8(buf[(column, row)].symbol()))
|
||||
.collect::<String>()
|
||||
.trim_end()
|
||||
.to_string()
|
||||
})
|
||||
.filter(|line| !line.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
insta::assert_snapshot!("wrapped_textarea_url_keeps_original_appearance", visible);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn composer_wrapped_url_fragments_keep_the_complete_destination() {
|
||||
let url = "https://github.com/openai/codex/pull/20252";
|
||||
let (sender, _receiver) = unbounded_channel::<AppEvent>();
|
||||
let mut composer = ChatComposer::new(
|
||||
/*has_input_focus*/ true,
|
||||
AppEventSender::new(sender),
|
||||
/*enhanced_keys_supported*/ true,
|
||||
"Ask Codex to do anything".to_string(),
|
||||
/*disable_paste_burst*/ false,
|
||||
);
|
||||
composer.set_text_content(format!("Fix CI on {url}"), Vec::new(), Vec::new());
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 34, /*height*/ 8,
|
||||
);
|
||||
let mut buf = Buffer::empty(area);
|
||||
|
||||
composer.render(area, &mut buf);
|
||||
|
||||
let linked = assert_destination(&buf, url);
|
||||
assert_eq!(linked_text(&linked), url);
|
||||
assert!(linked.windows(2).any(|pair| pair[0].1 != pair[1].1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scrolled_url_fragments_keep_the_offscreen_destination() {
|
||||
let url = "https://example.test/alpha/beta/gamma/delta/epsilon";
|
||||
let (_, state, buf) = render(url, /*width*/ 16, /*height*/ 2);
|
||||
let visible_url = linked_text(&assert_destination(&buf, url));
|
||||
assert!(state.scroll > 0);
|
||||
assert_ne!(visible_url, url);
|
||||
assert!(url.ends_with(&visible_url));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_drafts_reuse_hyperlink_detection_across_cursor_redraws() {
|
||||
let text = "x".repeat(MAX_USER_INPUT_TEXT_CHARS);
|
||||
let (mut textarea, mut state, mut buf) = render(&text, /*width*/ 80, /*height*/ 2);
|
||||
let area = buf.area;
|
||||
|
||||
let first_cache = {
|
||||
let cache = textarea.wrap_cache.borrow();
|
||||
let hyperlinks = cache.as_ref().unwrap().hyperlinks.get().unwrap();
|
||||
assert!(hyperlinks.hyperlinks.is_empty());
|
||||
hyperlinks as *const super::HyperlinkCache
|
||||
};
|
||||
|
||||
textarea.set_cursor(text.len() - 1);
|
||||
StatefulWidgetRef::render_ref(&(&textarea), area, &mut buf, &mut state);
|
||||
|
||||
let cache = textarea.wrap_cache.borrow();
|
||||
let second_cache = cache.as_ref().unwrap().hyperlinks.get().unwrap();
|
||||
assert!(std::ptr::eq(first_cache, second_cache));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maximum_length_urls_render_without_osc8_annotations() {
|
||||
let prefix = "https://example.test/";
|
||||
let text = format!(
|
||||
"{prefix}{}",
|
||||
"a".repeat(MAX_USER_INPUT_TEXT_CHARS - prefix.len())
|
||||
);
|
||||
let (textarea, _, buf) = render(&text, /*width*/ 80, /*height*/ 2);
|
||||
|
||||
assert_eq!(textarea.text(), text);
|
||||
assert_eq!(linked_cells(&buf), Vec::new());
|
||||
assert!(buf.content.iter().all(|cell| cell.symbol().len() == 1));
|
||||
insta::assert_snapshot!(
|
||||
"maximum_length_url_without_annotations",
|
||||
hyperlink_snapshot(&buf)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_urls_render_with_the_complete_destination() {
|
||||
let destination = "https://x.io";
|
||||
let text = format!("{destination} ").repeat(/*n*/ 50_000);
|
||||
let (textarea, state, buf) = render(&text, /*width*/ 80, /*height*/ 2);
|
||||
|
||||
assert!(state.scroll > 0);
|
||||
assert_destination(&buf, destination);
|
||||
assert_eq!(
|
||||
textarea
|
||||
.wrap_cache
|
||||
.borrow()
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.hyperlinks
|
||||
.get()
|
||||
.unwrap()
|
||||
.hyperlinks
|
||||
.len(),
|
||||
50_000
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joined_emoji_preserve_complete_url_cell_ranges() {
|
||||
let mut snapshots = Vec::new();
|
||||
for url in ["https://example.test/path", "https://example.test/👩💻/path"] {
|
||||
for width in [64, 24, 16] {
|
||||
let (_, _, buf) = render(&format!("👩💻 {url} tail"), width, /*height*/ 6);
|
||||
let linked = assert_destination(&buf, url);
|
||||
assert_eq!(linked_text(&linked), url);
|
||||
for row in 0..buf.area.height {
|
||||
let cells = linked
|
||||
.iter()
|
||||
.filter(|(_, y, _, _)| *y == row)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if let (Some(first), Some(last)) = (cells.first(), cells.last()) {
|
||||
snapshots.push(format!(
|
||||
"width {width}, row {row}, columns {}..={}: {}",
|
||||
first.0,
|
||||
last.0,
|
||||
linked_text(&cells)
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
insta::assert_snapshot!("joined_emoji_url_cell_ranges", snapshots.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unicode_whitespace_separates_url_destinations() {
|
||||
let first = "https://one.test/a";
|
||||
let second = "https://two.test/b";
|
||||
let mut snapshots = Vec::new();
|
||||
for separator in ['\u{2003}', '\u{a0}', '\u{2007}', '\u{202f}', '\u{2028}'] {
|
||||
for width in [48, 20] {
|
||||
let (_, _, buf) = render(
|
||||
&format!("{first}{separator}{second}"),
|
||||
width,
|
||||
/*height*/ 4,
|
||||
);
|
||||
let linked = linked_cells(&buf);
|
||||
for destination in [first, second] {
|
||||
let cells = linked
|
||||
.iter()
|
||||
.filter(|(_, _, _, target)| target == destination)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(linked_text(&cells), destination);
|
||||
}
|
||||
assert_eq!(linked.len(), first.len() + second.len());
|
||||
snapshots.push(format!(
|
||||
"separator U+{:04X}, width {width}\n{}",
|
||||
separator as u32,
|
||||
hyperlink_snapshot(&buf)
|
||||
));
|
||||
}
|
||||
}
|
||||
insta::assert_snapshot!("unicode_whitespace_url_destinations", snapshots.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hyperlink_cache_is_invalidated_when_text_changes() {
|
||||
let first = "https://one.test/before";
|
||||
let second = "https://two.test/after";
|
||||
let (mut textarea, mut state, mut buf) = render(first, /*width*/ 40, /*height*/ 1);
|
||||
let area = buf.area;
|
||||
|
||||
textarea.set_text_clearing_elements(second);
|
||||
assert!(textarea.wrap_cache.borrow().is_none());
|
||||
|
||||
buf = Buffer::empty(area);
|
||||
StatefulWidgetRef::render_ref(&(&textarea), area, &mut buf, &mut state);
|
||||
assert_eq!(linked_text(&assert_destination(&buf, second)), second);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distinct_urls_respect_punctuation_wide_prefixes_and_tabs() {
|
||||
let first = "https://one.test/a";
|
||||
let second = "https://two.test/b";
|
||||
let (_, _, buf) = render(
|
||||
&format!("界\t({first}),\n{second}!"),
|
||||
/*width*/ 48,
|
||||
/*height*/ 2,
|
||||
);
|
||||
let linked = linked_cells(&buf);
|
||||
for (row, column, destination) in [(0, 4, first), (1, 0, second)] {
|
||||
let cells = linked
|
||||
.iter()
|
||||
.filter(|(_, cell_row, _, _)| *cell_row == row)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(cells.first().map(|cell| cell.0), Some(column));
|
||||
assert!(cells.iter().all(|cell| cell.3 == destination));
|
||||
assert_eq!(linked_text(&cells), destination);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_hyperlinks_preserve_existing_highlight_styles() {
|
||||
let url = "https://example.test/highlight";
|
||||
let mut textarea = TextArea::new();
|
||||
textarea.insert_str(url);
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 1,
|
||||
);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let mut state = TextAreaState::default();
|
||||
let highlight = Style::default().fg(Color::Magenta);
|
||||
|
||||
textarea.render_ref_styled_with_highlights(
|
||||
area,
|
||||
&mut buf,
|
||||
&mut state,
|
||||
Style::default(),
|
||||
&[(0..url.len(), highlight)],
|
||||
);
|
||||
|
||||
let linked = assert_destination(&buf, url);
|
||||
assert_eq!(linked.len(), url.len());
|
||||
assert!(
|
||||
linked
|
||||
.iter()
|
||||
.all(|(column, row, _, _)| { buf[(*column, *row)].fg == Color::Magenta })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masked_url_input_never_exposes_hyperlink_destinations() {
|
||||
let url = "https://example.test/secret";
|
||||
let mut textarea = TextArea::new();
|
||||
textarea.insert_str(url);
|
||||
let area = Rect::new(
|
||||
/*x*/ 0, /*y*/ 0, /*width*/ 40, /*height*/ 1,
|
||||
);
|
||||
let mut buf = Buffer::empty(area);
|
||||
let mut state = TextAreaState::default();
|
||||
|
||||
textarea.render_ref_masked(area, &mut buf, &mut state, '*');
|
||||
|
||||
assert_eq!(linked_cells(&buf), Vec::new());
|
||||
assert!(
|
||||
area.positions()
|
||||
.all(|position| !buf[position].symbol().contains(url))
|
||||
);
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/textarea/hyperlinks_tests.rs
|
||||
expression: "snapshots.join(\"\\n\")"
|
||||
---
|
||||
width 64, row 0, columns 3..=27: https://example.test/path
|
||||
width 24, row 0, columns 3..=23: https://example.test/
|
||||
width 24, row 1, columns 0..=3: path
|
||||
width 16, row 0, columns 3..=10: https://
|
||||
width 16, row 1, columns 0..=12: example.test/
|
||||
width 16, row 2, columns 0..=3: path
|
||||
width 64, row 0, columns 3..=30: https://example.test/👩💻/path
|
||||
width 24, row 0, columns 3..=23: https://example.test/
|
||||
width 24, row 1, columns 0..=6: 👩💻/path
|
||||
width 16, row 0, columns 3..=10: https://
|
||||
width 16, row 1, columns 0..=15: example.test/👩💻/
|
||||
width 16, row 2, columns 0..=3: path
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/textarea/hyperlinks_tests.rs
|
||||
expression: hyperlink_snapshot(&buf)
|
||||
---
|
||||
|aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa|
|
||||
|aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa |
|
||||
Hyperlinks:
|
||||
none
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/textarea/hyperlinks_tests.rs
|
||||
expression: "snapshots.join(\"\\n\")"
|
||||
---
|
||||
separator U+2003, width 48
|
||||
|https://one.test/a https://two.test/b |
|
||||
| |
|
||||
| |
|
||||
| |
|
||||
Hyperlinks:
|
||||
row 0, columns 0..18 -> https://one.test/a
|
||||
row 0, columns 19..37 -> https://two.test/b
|
||||
|
||||
separator U+2003, width 20
|
||||
|https://one.test/a |
|
||||
|https://two.test/b |
|
||||
| |
|
||||
| |
|
||||
Hyperlinks:
|
||||
row 0, columns 0..18 -> https://one.test/a
|
||||
row 1, columns 0..18 -> https://two.test/b
|
||||
|
||||
separator U+00A0, width 48
|
||||
|https://one.test/a https://two.test/b |
|
||||
| |
|
||||
| |
|
||||
| |
|
||||
Hyperlinks:
|
||||
row 0, columns 0..18 -> https://one.test/a
|
||||
row 0, columns 19..37 -> https://two.test/b
|
||||
|
||||
separator U+00A0, width 20
|
||||
|https://one.test/ |
|
||||
|a https://two.test/b|
|
||||
| |
|
||||
| |
|
||||
Hyperlinks:
|
||||
row 0, columns 0..17 -> https://one.test/a
|
||||
row 1, columns 0..1 -> https://one.test/a
|
||||
row 1, columns 2..20 -> https://two.test/b
|
||||
|
||||
separator U+2007, width 48
|
||||
|https://one.test/a https://two.test/b |
|
||||
| |
|
||||
| |
|
||||
| |
|
||||
Hyperlinks:
|
||||
row 0, columns 0..18 -> https://one.test/a
|
||||
row 0, columns 19..37 -> https://two.test/b
|
||||
|
||||
separator U+2007, width 20
|
||||
|https://one.test/ |
|
||||
|a https://two.test/b|
|
||||
| |
|
||||
| |
|
||||
Hyperlinks:
|
||||
row 0, columns 0..17 -> https://one.test/a
|
||||
row 1, columns 0..1 -> https://one.test/a
|
||||
row 1, columns 2..20 -> https://two.test/b
|
||||
|
||||
separator U+202F, width 48
|
||||
|https://one.test/a https://two.test/b |
|
||||
| |
|
||||
| |
|
||||
| |
|
||||
Hyperlinks:
|
||||
row 0, columns 0..18 -> https://one.test/a
|
||||
row 0, columns 19..37 -> https://two.test/b
|
||||
|
||||
separator U+202F, width 20
|
||||
|https://one.test/ |
|
||||
|a https://two.test/b|
|
||||
| |
|
||||
| |
|
||||
Hyperlinks:
|
||||
row 0, columns 0..17 -> https://one.test/a
|
||||
row 1, columns 0..1 -> https://one.test/a
|
||||
row 1, columns 2..20 -> https://two.test/b
|
||||
|
||||
separator U+2028, width 48
|
||||
|https://one.test/a
https://two.test/b |
|
||||
| |
|
||||
| |
|
||||
| |
|
||||
Hyperlinks:
|
||||
row 0, columns 0..18 -> https://one.test/a
|
||||
row 0, columns 19..37 -> https://two.test/b
|
||||
|
||||
separator U+2028, width 20
|
||||
|https://one.test/a
|
|
||||
|https://two.test/b |
|
||||
| |
|
||||
| |
|
||||
Hyperlinks:
|
||||
row 0, columns 0..18 -> https://one.test/a
|
||||
row 1, columns 0..18 -> https://two.test/b
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
---
|
||||
source: tui/src/bottom_pane/textarea/hyperlinks_tests.rs
|
||||
expression: visible
|
||||
---
|
||||
Fix CI on https://
|
||||
github.com/openai/codex/
|
||||
pull/20252
|
||||
Reference in new issue
Block a user