mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-25 03:32:32 +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 ;;
|
-h|--help) usage; exit 0 ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
# Change to the Rust project root directory
|
# Resolve repository paths relative to this script so absolute invocation works
|
||||||
cd codex-rs
|
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)
|
# 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
|
# Use dev-fast profile by default for quick iteration
|
||||||
# Can override with: PROFILE=release ./build-fast.sh
|
# Can override with: PROFILE=release ./build-fast.sh
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ pub fn run(
|
|||||||
limit.get(),
|
limit.get(),
|
||||||
pattern.clone(),
|
pattern.clone(),
|
||||||
Matcher::new(nucleo_matcher::Config::DEFAULT),
|
Matcher::new(nucleo_matcher::Config::DEFAULT),
|
||||||
|
pattern_text.to_string(),
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -152,6 +153,9 @@ pub fn run(
|
|||||||
// that we can leverage the parallelism it provides.
|
// that we can leverage the parallelism it provides.
|
||||||
let mut walk_builder = WalkBuilder::new(search_directory);
|
let mut walk_builder = WalkBuilder::new(search_directory);
|
||||||
walk_builder.threads(num_walk_builder_threads);
|
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() {
|
if !exclude.is_empty() {
|
||||||
let mut override_builder = OverrideBuilder::new(search_directory);
|
let mut override_builder = OverrideBuilder::new(search_directory);
|
||||||
for exclude in exclude {
|
for exclude in exclude {
|
||||||
@@ -297,10 +301,12 @@ struct BestMatchesList {
|
|||||||
|
|
||||||
/// Internal buffer for converting strings to UTF-32.
|
/// Internal buffer for converting strings to UTF-32.
|
||||||
utf32buf: Vec<char>,
|
utf32buf: Vec<char>,
|
||||||
|
/// Original pattern text (for basename substring/prefix bonuses)
|
||||||
|
needle: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BestMatchesList {
|
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 {
|
Self {
|
||||||
max_count,
|
max_count,
|
||||||
num_matches: 0,
|
num_matches: 0,
|
||||||
@@ -308,6 +314,7 @@ impl BestMatchesList {
|
|||||||
matcher,
|
matcher,
|
||||||
binary_heap: BinaryHeap::new(),
|
binary_heap: BinaryHeap::new(),
|
||||||
utf32buf: Vec::<char>::new(),
|
utf32buf: Vec::<char>::new(),
|
||||||
|
needle,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,12 +325,18 @@ impl BestMatchesList {
|
|||||||
// non-match, so we can categorically increment the count here.
|
// non-match, so we can categorically increment the count here.
|
||||||
self.num_matches += 1;
|
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 {
|
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() {
|
} 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.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
+43
-25
@@ -322,39 +322,57 @@ impl App<'_> {
|
|||||||
|
|
||||||
match key_event {
|
match key_event {
|
||||||
KeyEvent { code: KeyCode::Esc, kind: KeyEventKind::Press | KeyEventKind::Repeat, .. } => {
|
KeyEvent { code: KeyCode::Esc, kind: KeyEventKind::Press | KeyEventKind::Repeat, .. } => {
|
||||||
// Double‑Esc behavior only when composer is empty
|
// Unified Esc policy (highest confidence first):
|
||||||
match &mut self.app_state {
|
// 1) If there's text, clear it.
|
||||||
AppState::Chat { widget } => {
|
// 2) Else if agent is running, stop it.
|
||||||
// 1) First Esc should stop any running task/stream immediately
|
// 3) Else double‑Esc engages backtrack/edit‑previous.
|
||||||
if widget.is_task_running() {
|
if let AppState::Chat { widget } = &mut self.app_state {
|
||||||
let _ = widget.on_ctrl_c();
|
// 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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
if widget.composer_is_empty() {
|
// Step 2: stop agent if running.
|
||||||
const THRESHOLD: Duration = Duration::from_millis(600);
|
if widget.is_task_running() {
|
||||||
let now = Instant::now();
|
let _ = widget.on_ctrl_c();
|
||||||
if let Some(prev) = self.last_esc_time {
|
// Arm double‑Esc so next Esc can trigger backtrack.
|
||||||
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
|
|
||||||
self.last_esc_time = Some(now);
|
self.last_esc_time = Some(now);
|
||||||
widget.show_esc_backtrack_hint();
|
|
||||||
continue;
|
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 {
|
KeyEvent {
|
||||||
code: KeyCode::Char('m'),
|
code: KeyCode::Char('m'),
|
||||||
|
|||||||
@@ -96,7 +96,6 @@ pub(crate) struct ChatComposer {
|
|||||||
token_usage_info: Option<TokenUsageInfo>,
|
token_usage_info: Option<TokenUsageInfo>,
|
||||||
has_focus: bool,
|
has_focus: bool,
|
||||||
has_chat_history: bool,
|
has_chat_history: bool,
|
||||||
last_esc_time: Option<std::time::Instant>,
|
|
||||||
is_task_running: bool,
|
is_task_running: bool,
|
||||||
// Current status message to display when task is running
|
// Current status message to display when task is running
|
||||||
status_message: String,
|
status_message: String,
|
||||||
@@ -146,7 +145,7 @@ impl ChatComposer {
|
|||||||
token_usage_info: None,
|
token_usage_info: None,
|
||||||
has_focus: has_input_focus,
|
has_focus: has_input_focus,
|
||||||
has_chat_history: false,
|
has_chat_history: false,
|
||||||
last_esc_time: None,
|
// no double‑Esc handling here; App manages Esc policy
|
||||||
is_task_running: false,
|
is_task_running: false,
|
||||||
status_message: String::from("coding"),
|
status_message: String::from("coding"),
|
||||||
animation_running: None,
|
animation_running: None,
|
||||||
@@ -297,11 +296,12 @@ impl ChatComposer {
|
|||||||
ActivePopup::File(c) => c.calculate_required_height(),
|
ActivePopup::File(c) => c.calculate_required_height(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Calculate actual content height of textarea
|
// IMPORTANT: `width` here is the full BottomPane width. When we render, the
|
||||||
// Width adjustments must mirror render exactly:
|
// composer is first given an outer horizontal padding of 1 on each side by
|
||||||
// In render, we allocate the input block, then compute its inner area (borders = 2),
|
// BottomPane (−2), then our input Block adds borders (−2), then we add inner
|
||||||
// then apply a horizontal Margin of 1 on each side (padding = 2). Total = 4 columns.
|
// horizontal padding via Margin::new(1, 0) (−2). Net: −6 columns.
|
||||||
let content_width = width.saturating_sub(4);
|
// 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
|
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
|
// Total input height: content + border (2) only, no vertical padding
|
||||||
@@ -413,6 +413,15 @@ impl ChatComposer {
|
|||||||
true
|
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.
|
/// Integrate results from an asynchronous file search.
|
||||||
pub(crate) fn on_file_search_result(&mut self, query: String, matches: Vec<FileMatch>) {
|
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.
|
// Handle one-off Tab-triggered case first: only open if matches exist.
|
||||||
@@ -460,6 +469,17 @@ impl ChatComposer {
|
|||||||
self.sync_file_search_popup();
|
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.
|
/// Handle a key event coming from the main UI.
|
||||||
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
pub fn handle_key_event(&mut self, key_event: KeyEvent) -> (InputResult, bool) {
|
||||||
// Any non-Down key clears the sticky flag; handled before popup routing
|
// Any non-Down key clears the sticky flag; handled before popup routing
|
||||||
@@ -895,31 +915,10 @@ impl ChatComposer {
|
|||||||
(InputResult::None, false)
|
(InputResult::None, false)
|
||||||
}
|
}
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
// Handle Esc key for double-press clearing
|
// Handle Esc key — leave to App-level policy (clear/stop/backtrack)
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
KeyEvent {
|
KeyEvent { code: KeyCode::Esc, .. } => {
|
||||||
code: KeyCode::Esc, ..
|
// Do nothing here so App can implement global Esc ordering.
|
||||||
} => {
|
|
||||||
// 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);
|
|
||||||
(InputResult::None, false)
|
(InputResult::None, false)
|
||||||
}
|
}
|
||||||
// -------------------------------------------------------------
|
// -------------------------------------------------------------
|
||||||
@@ -1137,9 +1136,9 @@ impl ChatComposer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Only trigger file search when the token has at least 2 characters to
|
// Trigger file search as soon as at least 1 character is typed.
|
||||||
// reduce churn while typing the first character.
|
// The popup shows an idle hint for an empty query handled above.
|
||||||
if query.chars().count() >= 2 {
|
if query.chars().count() >= 1 {
|
||||||
self.app_event_tx
|
self.app_event_tx
|
||||||
.send(AppEvent::StartFileSearch(query.clone()));
|
.send(AppEvent::StartFileSearch(query.clone()));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use ratatui::buffer::Buffer;
|
|||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::layout::Margin;
|
use ratatui::layout::Margin;
|
||||||
use ratatui::widgets::WidgetRef;
|
use ratatui::widgets::WidgetRef;
|
||||||
|
use ratatui::prelude::Stylize;
|
||||||
|
|
||||||
use super::popup_consts::MAX_POPUP_ROWS;
|
use super::popup_consts::MAX_POPUP_ROWS;
|
||||||
use super::scroll_state::ScrollState;
|
use super::scroll_state::ScrollState;
|
||||||
@@ -124,37 +125,36 @@ impl WidgetRef for &FileSearchPopup {
|
|||||||
// rows align with the text inside the composer (border + inner pad).
|
// rows align with the text inside the composer (border + inner pad).
|
||||||
let indented_area = area.inner(Margin::new(2, 0));
|
let indented_area = area.inner(Margin::new(2, 0));
|
||||||
// Convert matches to GenericDisplayRow, translating indices to usize at the UI boundary.
|
// Convert matches to GenericDisplayRow, translating indices to usize at the UI boundary.
|
||||||
let rows_all: Vec<GenericDisplayRow> = if self.matches.is_empty() {
|
let rows_all: Vec<GenericDisplayRow> = self
|
||||||
Vec::new()
|
.matches
|
||||||
} else {
|
.iter()
|
||||||
self.matches
|
.map(|m| GenericDisplayRow {
|
||||||
.iter()
|
name: m.path.clone(),
|
||||||
.map(|m| GenericDisplayRow {
|
match_indices: m
|
||||||
name: m.path.clone(),
|
.indices
|
||||||
match_indices: m
|
.as_ref()
|
||||||
.indices
|
.map(|v| v.iter().map(|&i| i as usize).collect()),
|
||||||
.as_ref()
|
is_current: false,
|
||||||
.map(|v| v.iter().map(|&i| i as usize).collect()),
|
description: None,
|
||||||
is_current: false,
|
// Use default text color for file matches
|
||||||
description: None,
|
name_color: None,
|
||||||
// Use default text color for file matches
|
})
|
||||||
name_color: None,
|
.collect();
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
};
|
|
||||||
|
|
||||||
if self.waiting && rows_all.is_empty() {
|
if self.waiting && rows_all.is_empty() {
|
||||||
// Render a minimal waiting stub using the shared renderer (no rows -> "no matches").
|
// Show a friendly "searching…" placeholder instead of "no matches" while waiting
|
||||||
render_rows(indented_area, buf, &[], &self.state, MAX_POPUP_ROWS, false);
|
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 {
|
} else {
|
||||||
render_rows(
|
render_rows(indented_area, buf, &rows_all, &self.state, MAX_POPUP_ROWS, false);
|
||||||
indented_area,
|
|
||||||
buf,
|
|
||||||
&rows_all,
|
|
||||||
&self.state,
|
|
||||||
MAX_POPUP_ROWS,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -230,6 +230,24 @@ impl BottomPane<'_> {
|
|||||||
self.request_redraw();
|
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
|
/// Enable or disable compact compose mode. When enabled, the spacer line
|
||||||
/// above the input composer is removed so the history can scroll into that
|
/// above the input composer is removed so the history can scroll into that
|
||||||
/// row. This is typically toggled when the user scrolls up.
|
/// 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() }
|
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.
|
/// Forward an `Op` directly to codex.
|
||||||
pub(crate) fn submit_op(&self, op: Op) {
|
pub(crate) fn submit_op(&self, op: Op) {
|
||||||
if let Err(e) = self.codex_op_tx.send(op) {
|
if let Err(e) = self.codex_op_tx.send(op) {
|
||||||
|
|||||||
@@ -164,8 +164,10 @@ impl FileSearchManager {
|
|||||||
) {
|
) {
|
||||||
let compute_indices = true;
|
let compute_indices = true;
|
||||||
std::thread::spawn(move || {
|
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(
|
let matches = file_search::run(
|
||||||
&query,
|
&search_query,
|
||||||
MAX_FILE_SEARCH_RESULTS,
|
MAX_FILE_SEARCH_RESULTS,
|
||||||
&search_dir,
|
&search_dir,
|
||||||
Vec::new(),
|
Vec::new(),
|
||||||
|
|||||||
@@ -434,13 +434,64 @@ fn autolink_spans(spans: Vec<Span<'static>>) -> Vec<Span<'static>> {
|
|||||||
let url_re = EXPL_URL_RE
|
let url_re = EXPL_URL_RE
|
||||||
.get_or_init(|| Regex::new(r"(?i)\bhttps?://[^\s)]+").unwrap());
|
.get_or_init(|| Regex::new(r"(?i)\bhttps?://[^\s)]+").unwrap());
|
||||||
let dom_re = DOMAIN_RE.get_or_init(|| {
|
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\-./?%&=#]*)?)")
|
Regex::new(r"\b([a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)+(?:/[\w\-./?%&=#]*)?)")
|
||||||
.unwrap()
|
.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());
|
let mut out: Vec<Span<'static>> = Vec::with_capacity(spans.len());
|
||||||
for s in spans {
|
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 text = s.content.clone();
|
||||||
let mut cursor = 0usize;
|
let mut cursor = 0usize;
|
||||||
let mut changed = false;
|
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();
|
span.content = text[cursor..start].to_string().into();
|
||||||
out.push(span);
|
out.push(span);
|
||||||
}
|
}
|
||||||
let url = &text[start..end];
|
let raw = &text[start..end];
|
||||||
out.push(hyperlink_span(url, url, &s));
|
let (core, trailing) = split_trailing_punct(raw);
|
||||||
cursor = end;
|
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;
|
changed = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -494,15 +551,27 @@ fn autolink_spans(spans: Vec<Span<'static>>) -> Vec<Span<'static>> {
|
|||||||
span.content = text[cursor..start].to_string().into();
|
span.content = text[cursor..start].to_string().into();
|
||||||
out.push(span);
|
out.push(span);
|
||||||
}
|
}
|
||||||
let dom = &text[start..end];
|
let raw = &text[start..end];
|
||||||
// Skip if it obviously looks like an email or already in a link
|
let (core_dom, trailing) = split_trailing_punct(raw);
|
||||||
if !dom.contains('@') {
|
if is_probable_domain(core_dom) {
|
||||||
let target = format!("https://{dom}");
|
let target = format!("https://{core_dom}");
|
||||||
out.push(hyperlink_span(dom, &target, &s));
|
out.push(hyperlink_span(core_dom, &target, &s));
|
||||||
cursor = end;
|
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;
|
changed = true;
|
||||||
continue;
|
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
|
// No more matches
|
||||||
|
|||||||
Reference in New Issue
Block a user