mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:23:33 +00:00
chore: commit pending changes after successful build (./build-fast.sh)
This commit is contained in:
+6
-3
@@ -27,11 +27,14 @@ case "${1:-}" in
|
||||
-h|--help) usage; exit 0 ;;
|
||||
esac
|
||||
|
||||
# Change to the Rust project root directory
|
||||
cd codex-rs
|
||||
# Resolve repository paths relative to this script so absolute invocation works
|
||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd)"
|
||||
|
||||
# Change to the Rust project root directory (codex-rs) regardless of caller CWD
|
||||
cd "${SCRIPT_DIR}/codex-rs"
|
||||
|
||||
# Compute repository root (one level up from codex-rs)
|
||||
REPO_ROOT="$(cd .. && pwd)"
|
||||
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
|
||||
|
||||
# Use dev-fast profile by default for quick iteration
|
||||
# Can override with: PROFILE=release ./build-fast.sh
|
||||
|
||||
@@ -144,6 +144,7 @@ pub fn run(
|
||||
limit.get(),
|
||||
pattern.clone(),
|
||||
Matcher::new(nucleo_matcher::Config::DEFAULT),
|
||||
pattern_text.to_string(),
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
@@ -152,6 +153,9 @@ pub fn run(
|
||||
// that we can leverage the parallelism it provides.
|
||||
let mut walk_builder = WalkBuilder::new(search_directory);
|
||||
walk_builder.threads(num_walk_builder_threads);
|
||||
// Include everything by default — do NOT honor .gitignore or global ignore files.
|
||||
// The UI may de-prioritize commonly ignored paths, but completions should see all files.
|
||||
walk_builder.ignore(false).git_ignore(false).git_global(false).git_exclude(false).hidden(false);
|
||||
if !exclude.is_empty() {
|
||||
let mut override_builder = OverrideBuilder::new(search_directory);
|
||||
for exclude in exclude {
|
||||
@@ -297,10 +301,12 @@ struct BestMatchesList {
|
||||
|
||||
/// Internal buffer for converting strings to UTF-32.
|
||||
utf32buf: Vec<char>,
|
||||
/// Original pattern text (for basename substring/prefix bonuses)
|
||||
needle: String,
|
||||
}
|
||||
|
||||
impl BestMatchesList {
|
||||
fn new(max_count: usize, pattern: Pattern, matcher: Matcher) -> Self {
|
||||
fn new(max_count: usize, pattern: Pattern, matcher: Matcher, needle: String) -> Self {
|
||||
Self {
|
||||
max_count,
|
||||
num_matches: 0,
|
||||
@@ -308,6 +314,7 @@ impl BestMatchesList {
|
||||
matcher,
|
||||
binary_heap: BinaryHeap::new(),
|
||||
utf32buf: Vec::<char>::new(),
|
||||
needle,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,12 +325,18 @@ impl BestMatchesList {
|
||||
// non-match, so we can categorically increment the count here.
|
||||
self.num_matches += 1;
|
||||
|
||||
// Apply light penalties to commonly ignored paths so they rank lower
|
||||
// while still being available in results.
|
||||
let penalty = path_penalty(line);
|
||||
let bonus = basename_bonus(line, &self.needle);
|
||||
let eff_score = score.saturating_add(bonus).saturating_sub(penalty);
|
||||
|
||||
if self.binary_heap.len() < self.max_count {
|
||||
self.binary_heap.push(Reverse((score, line.to_string())));
|
||||
self.binary_heap.push(Reverse((eff_score, line.to_string())));
|
||||
} else if let Some(min_element) = self.binary_heap.peek() {
|
||||
if score > min_element.0.0 {
|
||||
if eff_score > min_element.0.0 {
|
||||
self.binary_heap.pop();
|
||||
self.binary_heap.push(Reverse((score, line.to_string())));
|
||||
self.binary_heap.push(Reverse((eff_score, line.to_string())));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -363,6 +376,29 @@ fn create_pattern(pattern: &str) -> Pattern {
|
||||
)
|
||||
}
|
||||
|
||||
fn path_penalty(path: &str) -> u32 {
|
||||
let mut p: u32 = 0;
|
||||
// Penalize large deps/build dirs heavily
|
||||
if path.contains("/node_modules/") { p = p.saturating_add(300); }
|
||||
if path.contains("/target/") { p = p.saturating_add(240); }
|
||||
if path.contains("/dist/") || path.contains("/build/") { p = p.saturating_add(120); }
|
||||
// Penalize hidden segments a bit (except common VCS dirs kept low)
|
||||
for seg in path.split('/') {
|
||||
if seg.starts_with('.') && seg != "." && seg != ".." && seg != ".github" { p = p.saturating_add(40); }
|
||||
}
|
||||
p
|
||||
}
|
||||
|
||||
fn basename_bonus(path: &str, needle: &str) -> u32 {
|
||||
if needle.is_empty() { return 0; }
|
||||
let file = match path.rsplit_once('/') { Some((_, f)) => f, None => path };
|
||||
let file_l = file.to_lowercase();
|
||||
let needle_l = needle.to_lowercase();
|
||||
if file_l.starts_with(&needle_l) { return 400; }
|
||||
if file_l.contains(&needle_l) { return 150; }
|
||||
0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+43
-25
@@ -322,39 +322,57 @@ impl App<'_> {
|
||||
|
||||
match key_event {
|
||||
KeyEvent { code: KeyCode::Esc, kind: KeyEventKind::Press | KeyEventKind::Repeat, .. } => {
|
||||
// Double‑Esc behavior only when composer is empty
|
||||
match &mut self.app_state {
|
||||
AppState::Chat { widget } => {
|
||||
// 1) First Esc should stop any running task/stream immediately
|
||||
if widget.is_task_running() {
|
||||
let _ = widget.on_ctrl_c();
|
||||
// Unified Esc policy (highest confidence first):
|
||||
// 1) If there's text, clear it.
|
||||
// 2) Else if agent is running, stop it.
|
||||
// 3) Else double‑Esc engages backtrack/edit‑previous.
|
||||
if let AppState::Chat { widget } = &mut self.app_state {
|
||||
// If a file-search popup is visible, close it first
|
||||
// then continue with global Esc policy in the same keypress.
|
||||
let closed_file_popup = widget.close_file_popup_if_active();
|
||||
// If any other modal/overlay view is active, do NOT
|
||||
// run global Esc policy; the view will handle Esc itself.
|
||||
if !closed_file_popup && widget.has_active_modal_view() {
|
||||
// Do nothing else; the view consumed Esc.
|
||||
} else {
|
||||
let now = Instant::now();
|
||||
const THRESHOLD: Duration = Duration::from_millis(600);
|
||||
|
||||
// Step 1: clear composer text if present.
|
||||
if !widget.composer_is_empty() {
|
||||
widget.clear_composer();
|
||||
// Arm double‑Esc so a quick second Esc proceeds to steps 2/3.
|
||||
self.last_esc_time = Some(now);
|
||||
continue;
|
||||
}
|
||||
|
||||
if widget.composer_is_empty() {
|
||||
const THRESHOLD: Duration = Duration::from_millis(600);
|
||||
let now = Instant::now();
|
||||
if let Some(prev) = self.last_esc_time {
|
||||
if now.duration_since(prev) <= THRESHOLD {
|
||||
// second Esc: either undo jump-back, or open picker
|
||||
self.last_esc_time = None;
|
||||
if widget.has_pending_jump_back() {
|
||||
widget.undo_jump_back();
|
||||
} else {
|
||||
widget.show_edit_previous_picker();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// first Esc: flash hint
|
||||
// Step 2: stop agent if running.
|
||||
if widget.is_task_running() {
|
||||
let _ = widget.on_ctrl_c();
|
||||
// Arm double‑Esc so next Esc can trigger backtrack.
|
||||
self.last_esc_time = Some(now);
|
||||
widget.show_esc_backtrack_hint();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Step 3: backtrack via double‑Esc.
|
||||
if let Some(prev) = self.last_esc_time {
|
||||
if now.duration_since(prev) <= THRESHOLD {
|
||||
self.last_esc_time = None;
|
||||
if widget.has_pending_jump_back() {
|
||||
widget.undo_jump_back();
|
||||
} else {
|
||||
widget.show_edit_previous_picker();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// First Esc in empty/idle state: show hint and arm timer.
|
||||
self.last_esc_time = Some(now);
|
||||
widget.show_esc_backtrack_hint();
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
// Otherwise fall through to normal dispatch
|
||||
// Otherwise fall through
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('m'),
|
||||
|
||||
@@ -96,7 +96,6 @@ pub(crate) struct ChatComposer {
|
||||
token_usage_info: Option<TokenUsageInfo>,
|
||||
has_focus: bool,
|
||||
has_chat_history: bool,
|
||||
last_esc_time: Option<std::time::Instant>,
|
||||
is_task_running: bool,
|
||||
// Current status message to display when task is running
|
||||
status_message: String,
|
||||
@@ -146,7 +145,7 @@ impl ChatComposer {
|
||||
token_usage_info: None,
|
||||
has_focus: has_input_focus,
|
||||
has_chat_history: false,
|
||||
last_esc_time: None,
|
||||
// no double‑Esc handling here; App manages Esc policy
|
||||
is_task_running: false,
|
||||
status_message: String::from("coding"),
|
||||
animation_running: None,
|
||||
@@ -297,11 +296,12 @@ impl ChatComposer {
|
||||
ActivePopup::File(c) => c.calculate_required_height(),
|
||||
};
|
||||
|
||||
// Calculate actual content height of textarea
|
||||
// Width adjustments must mirror render exactly:
|
||||
// In render, we allocate the input block, then compute its inner area (borders = 2),
|
||||
// then apply a horizontal Margin of 1 on each side (padding = 2). Total = 4 columns.
|
||||
let content_width = width.saturating_sub(4);
|
||||
// IMPORTANT: `width` here is the full BottomPane width. When we render, the
|
||||
// composer is first given an outer horizontal padding of 1 on each side by
|
||||
// BottomPane (−2), then our input Block adds borders (−2), then we add inner
|
||||
// horizontal padding via Margin::new(1, 0) (−2). Net: −6 columns.
|
||||
// To match wrapping exactly, use width−6 for the TextArea content width.
|
||||
let content_width = width.saturating_sub(6);
|
||||
let content_lines = self.textarea.desired_height(content_width).max(1); // At least 1 line
|
||||
|
||||
// Total input height: content + border (2) only, no vertical padding
|
||||
@@ -413,6 +413,15 @@ impl ChatComposer {
|
||||
true
|
||||
}
|
||||
|
||||
|
||||
/// Clear all composer input and reset transient state like pending pastes
|
||||
/// and history navigation.
|
||||
pub(crate) fn clear_text(&mut self) {
|
||||
self.textarea.set_text("");
|
||||
self.pending_pastes.clear();
|
||||
self.history.reset_navigation();
|
||||
}
|
||||
|
||||
/// Integrate results from an asynchronous file search.
|
||||
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
|
||||
// Handle one-off Tab-triggered case first: only open if matches exist.
|
||||
@@ -460,6 +469,17 @@ impl ChatComposer {
|
||||
self.sync_file_search_popup();
|
||||
}
|
||||
|
||||
/// Close the file-search popup if it is currently active. Returns true if closed.
|
||||
pub(crate) fn close_file_popup_if_active(&mut self) -> bool {
|
||||
match self.active_popup {
|
||||
ActivePopup::File(_) => {
|
||||
self.active_popup = ActivePopup::None;
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a key event coming from the main UI.
|
||||
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
||||
// Any non-Down key clears the sticky flag; handled before popup routing
|
||||
@@ -895,31 +915,10 @@ impl ChatComposer {
|
||||
(InputResult::None, false)
|
||||
}
|
||||
// -------------------------------------------------------------
|
||||
// Handle Esc key for double-press clearing
|
||||
// Handle Esc key — leave to App-level policy (clear/stop/backtrack)
|
||||
// -------------------------------------------------------------
|
||||
KeyEvent {
|
||||
code: KeyCode::Esc, ..
|
||||
} => {
|
||||
// Check if this is a double-Esc press
|
||||
const DOUBLE_ESC_THRESHOLD: std::time::Duration =
|
||||
std::time::Duration::from_millis(500);
|
||||
let now = std::time::Instant::now();
|
||||
|
||||
if let Some(last_time) = self.last_esc_time {
|
||||
if now.duration_since(last_time) < DOUBLE_ESC_THRESHOLD
|
||||
&& !self.textarea.is_empty()
|
||||
{
|
||||
// Double-Esc detected, clear the input field
|
||||
self.textarea.set_text("");
|
||||
self.pending_pastes.clear();
|
||||
self.last_esc_time = None;
|
||||
self.history.reset_navigation();
|
||||
return (InputResult::None, true);
|
||||
}
|
||||
}
|
||||
|
||||
// Single Esc - just record the time
|
||||
self.last_esc_time = Some(now);
|
||||
KeyEvent { code: KeyCode::Esc, .. } => {
|
||||
// Do nothing here so App can implement global Esc ordering.
|
||||
(InputResult::None, false)
|
||||
}
|
||||
// -------------------------------------------------------------
|
||||
@@ -1137,9 +1136,9 @@ impl ChatComposer {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only trigger file search when the token has at least 2 characters to
|
||||
// reduce churn while typing the first character.
|
||||
if query.chars().count() >= 2 {
|
||||
// Trigger file search as soon as at least 1 character is typed.
|
||||
// The popup shows an idle hint for an empty query handled above.
|
||||
if query.chars().count() >= 1 {
|
||||
self.app_event_tx
|
||||
.send(AppEvent::StartFileSearch(query.clone()));
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::layout::Margin;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
use ratatui::prelude::Stylize;
|
||||
|
||||
use super::popup_consts::MAX_POPUP_ROWS;
|
||||
use super::scroll_state::ScrollState;
|
||||
@@ -124,37 +125,36 @@ impl WidgetRef for &FileSearchPopup {
|
||||
// rows align with the text inside the composer (border + inner pad).
|
||||
let indented_area = area.inner(Margin::new(2, 0));
|
||||
// Convert matches to GenericDisplayRow, translating indices to usize at the UI boundary.
|
||||
let rows_all: Vec<GenericDisplayRow> = if self.matches.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.matches
|
||||
.iter()
|
||||
.map(|m| GenericDisplayRow {
|
||||
name: m.path.clone(),
|
||||
match_indices: m
|
||||
.indices
|
||||
.as_ref()
|
||||
.map(|v| v.iter().map(|&i| i as usize).collect()),
|
||||
is_current: false,
|
||||
description: None,
|
||||
// Use default text color for file matches
|
||||
name_color: None,
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let rows_all: Vec<GenericDisplayRow> = self
|
||||
.matches
|
||||
.iter()
|
||||
.map(|m| GenericDisplayRow {
|
||||
name: m.path.clone(),
|
||||
match_indices: m
|
||||
.indices
|
||||
.as_ref()
|
||||
.map(|v| v.iter().map(|&i| i as usize).collect()),
|
||||
is_current: false,
|
||||
description: None,
|
||||
// Use default text color for file matches
|
||||
name_color: None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if self.waiting && rows_all.is_empty() {
|
||||
// Render a minimal waiting stub using the shared renderer (no rows -> "no matches").
|
||||
render_rows(indented_area, buf, &[], &self.state, MAX_POPUP_ROWS, false);
|
||||
// Show a friendly "searching…" placeholder instead of "no matches" while waiting
|
||||
let msg = "searching…";
|
||||
// Draw centered within the first row of the hint area
|
||||
let x = indented_area.x;
|
||||
let y = indented_area.y;
|
||||
let w = indented_area.width;
|
||||
let start = x.saturating_add(w.saturating_sub(msg.len() as u16) / 2);
|
||||
for xi in x..x + w {
|
||||
buf[(xi, y)].set_char(' ');
|
||||
}
|
||||
buf.set_string(start, y, msg, ratatui::style::Style::default().dim());
|
||||
} else {
|
||||
render_rows(
|
||||
indented_area,
|
||||
buf,
|
||||
&rows_all,
|
||||
&self.state,
|
||||
MAX_POPUP_ROWS,
|
||||
false,
|
||||
);
|
||||
render_rows(indented_area, buf, &rows_all, &self.state, MAX_POPUP_ROWS, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -230,6 +230,24 @@ impl BottomPane<'_> {
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Clear the composer text and reset transient composer state.
|
||||
pub(crate) fn clear_composer(&mut self) {
|
||||
self.composer.clear_text();
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
/// Attempt to close the file-search popup if visible. Returns true if closed.
|
||||
pub(crate) fn close_file_popup_if_active(&mut self) -> bool {
|
||||
let closed = self.composer.close_file_popup_if_active();
|
||||
if closed { self.request_redraw(); }
|
||||
closed
|
||||
}
|
||||
|
||||
/// True if a modal/overlay view is currently displayed (not the composer popup).
|
||||
pub(crate) fn has_active_modal_view(&self) -> bool {
|
||||
self.active_view.is_some()
|
||||
}
|
||||
|
||||
/// Enable or disable compact compose mode. When enabled, the spacer line
|
||||
/// above the input composer is removed so the history can scroll into that
|
||||
/// row. This is typically toggled when the user scrolls up.
|
||||
|
||||
@@ -3716,6 +3716,24 @@ impl ChatWidget<'_> {
|
||||
|
||||
pub(crate) fn has_pending_jump_back(&self) -> bool { self.pending_jump_back.is_some() }
|
||||
|
||||
/// Clear the composer text and any pending paste placeholders/history cursors.
|
||||
pub(crate) fn clear_composer(&mut self) {
|
||||
self.bottom_pane.clear_composer();
|
||||
// Mark a height change so layout adjusts immediately if the composer shrinks.
|
||||
self.height_manager
|
||||
.borrow_mut()
|
||||
.record_event(crate::height_manager::HeightEvent::ComposerModeChange);
|
||||
self.request_redraw();
|
||||
}
|
||||
|
||||
pub(crate) fn close_file_popup_if_active(&mut self) -> bool {
|
||||
self.bottom_pane.close_file_popup_if_active()
|
||||
}
|
||||
|
||||
pub(crate) fn has_active_modal_view(&self) -> bool {
|
||||
self.bottom_pane.has_active_modal_view()
|
||||
}
|
||||
|
||||
/// Forward an `Op` directly to codex.
|
||||
pub(crate) fn submit_op(&self, op: Op) {
|
||||
if let Err(e) = self.codex_op_tx.send(op) {
|
||||
|
||||
@@ -164,8 +164,10 @@ impl FileSearchManager {
|
||||
) {
|
||||
let compute_indices = true;
|
||||
std::thread::spawn(move || {
|
||||
// Normalize the query: strip leading "./" so it matches repo-relative paths.
|
||||
let search_query = query.strip_prefix("./").unwrap_or(&query).to_string();
|
||||
let matches = file_search::run(
|
||||
&query,
|
||||
&search_query,
|
||||
MAX_FILE_SEARCH_RESULTS,
|
||||
&search_dir,
|
||||
Vec::new(),
|
||||
|
||||
@@ -434,13 +434,64 @@ fn autolink_spans(spans: Vec<Span<'static>>) -> Vec<Span<'static>> {
|
||||
let url_re = EXPL_URL_RE
|
||||
.get_or_init(|| Regex::new(r"(?i)\bhttps?://[^\s)]+").unwrap());
|
||||
let dom_re = DOMAIN_RE.get_or_init(|| {
|
||||
// e.g., apps.shopify.com or foo.example.io/path?x=1
|
||||
// Conservative bare-domain matcher (no scheme). Examples:
|
||||
// apps.shopify.com
|
||||
// foo.example.io/path?x=1
|
||||
// It intentionally over-matches a bit; we further filter below.
|
||||
Regex::new(r"\b([a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+(?:/[\w\-./?%&=#]*)?)")
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
// Trim common trailing punctuation from a detected URL/domain, returning
|
||||
// (core, trailing). The trailing part will be emitted as normal text (not
|
||||
// hyperlinked) so that tokens like "example.com." don’t include the period.
|
||||
fn split_trailing_punct(s: &str) -> (&str, &str) {
|
||||
let bytes = s.as_bytes();
|
||||
let mut end = bytes.len();
|
||||
while end > 0 {
|
||||
let ch = bytes[end - 1] as char;
|
||||
if ")]}>'\".,!?;:".contains(ch) { // common trailing punctuation
|
||||
end -= 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
(&s[..end], &s[end..])
|
||||
}
|
||||
|
||||
// Additional heuristic to avoid false positives like "e.g." or
|
||||
// "filename.rs" by requiring a well-known TLD. Precision over recall.
|
||||
fn is_probable_domain(dom: &str) -> bool {
|
||||
if dom.contains('@') { return false; }
|
||||
let dot_count = dom.matches('.').count();
|
||||
if dot_count == 0 { return false; }
|
||||
|
||||
// Extract the final label (candidate TLD) and normalize.
|
||||
let tld = dom
|
||||
.rsplit_once('.')
|
||||
.map(|(_, t)| t)
|
||||
.unwrap_or("")
|
||||
.trim_matches(|c: char| !c.is_ascii_alphanumeric())
|
||||
.to_ascii_lowercase();
|
||||
|
||||
// Small allowlist of popular TLDs. This intentionally excludes
|
||||
// language/file extensions like `.rs`, `.ts`, `.php`, etc.
|
||||
const ALLOWED_TLDS: &[&str] = &[
|
||||
"com","org","net","edu","gov","io","ai","app","dev","co","us","uk","ca","de","fr","jp","cn","in","au"
|
||||
];
|
||||
|
||||
ALLOWED_TLDS.contains(&tld.as_str())
|
||||
}
|
||||
|
||||
let mut out: Vec<Span<'static>> = Vec::with_capacity(spans.len());
|
||||
for s in spans {
|
||||
// Skip autolinking inside inline code spans (we style code with the
|
||||
// theme's function color). This avoids linking snippets like
|
||||
// `curl https://example.com` or code identifiers containing dots.
|
||||
if s.style.fg == Some(crate::colors::function()) {
|
||||
out.push(s);
|
||||
continue;
|
||||
}
|
||||
let text = s.content.clone();
|
||||
let mut cursor = 0usize;
|
||||
let mut changed = false;
|
||||
@@ -478,9 +529,15 @@ fn autolink_spans(spans: Vec<Span<'static>>) -> Vec<Span<'static>> {
|
||||
span.content = text[cursor..start].to_string().into();
|
||||
out.push(span);
|
||||
}
|
||||
let url = &text[start..end];
|
||||
out.push(hyperlink_span(url, url, &s));
|
||||
cursor = end;
|
||||
let raw = &text[start..end];
|
||||
let (core, trailing) = split_trailing_punct(raw);
|
||||
out.push(hyperlink_span(core, core, &s));
|
||||
if !trailing.is_empty() {
|
||||
let mut span = s.clone();
|
||||
span.content = trailing.to_string().into();
|
||||
out.push(span);
|
||||
}
|
||||
cursor = start + core.len() + trailing.len();
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
@@ -494,15 +551,27 @@ fn autolink_spans(spans: Vec<Span<'static>>) -> Vec<Span<'static>> {
|
||||
span.content = text[cursor..start].to_string().into();
|
||||
out.push(span);
|
||||
}
|
||||
let dom = &text[start..end];
|
||||
// Skip if it obviously looks like an email or already in a link
|
||||
if !dom.contains('@') {
|
||||
let target = format!("https://{dom}");
|
||||
out.push(hyperlink_span(dom, &target, &s));
|
||||
cursor = end;
|
||||
let raw = &text[start..end];
|
||||
let (core_dom, trailing) = split_trailing_punct(raw);
|
||||
if is_probable_domain(core_dom) {
|
||||
let target = format!("https://{core_dom}");
|
||||
out.push(hyperlink_span(core_dom, &target, &s));
|
||||
if !trailing.is_empty() {
|
||||
let mut span = s.clone();
|
||||
span.content = trailing.to_string().into();
|
||||
out.push(span);
|
||||
}
|
||||
cursor = start + core_dom.len() + trailing.len();
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
// Not a probable domain; emit raw text
|
||||
let mut span = s.clone();
|
||||
span.content = raw.to_string().into();
|
||||
out.push(span);
|
||||
cursor = end;
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// No more matches
|
||||
|
||||
Reference in New Issue
Block a user