Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 20 additions & 2 deletions codex-rs/file-search/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ pub struct FileMatch {
pub score: u32,
pub path: PathBuf,
pub root: PathBuf,
pub is_dir: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub indices: Option<Vec<u32>>, // Sorted & deduplicated when present
}
Expand Down Expand Up @@ -93,6 +94,7 @@ pub struct FileSearchOptions {
pub threads: NonZero<usize>,
pub compute_indices: bool,
pub respect_gitignore: bool,
pub include_dirs: bool,
}

impl Default for FileSearchOptions {
Expand All @@ -105,6 +107,7 @@ impl Default for FileSearchOptions {
threads: NonZero::new(2).unwrap(),
compute_indices: false,
respect_gitignore: true,
include_dirs: false,
}
}
}
Expand Down Expand Up @@ -163,6 +166,7 @@ fn create_session_inner(
threads,
compute_indices,
respect_gitignore,
include_dirs,
} = options;

let Some(primary_search_directory) = search_directories.first() else {
Expand Down Expand Up @@ -191,6 +195,7 @@ fn create_session_inner(
threads: threads.get(),
compute_indices,
respect_gitignore,
include_dirs,
cancelled: cancelled.clone(),
shutdown: Arc::new(AtomicBool::new(false)),
reporter,
Expand Down Expand Up @@ -266,6 +271,7 @@ pub async fn run_main<T: Reporter>(
threads,
compute_indices,
respect_gitignore: true,
include_dirs: false,
},
None,
)?;
Expand Down Expand Up @@ -344,6 +350,7 @@ struct SessionInner {
threads: usize,
compute_indices: bool,
respect_gitignore: bool,
include_dirs: bool,
cancelled: Arc<AtomicBool>,
shutdown: Arc<AtomicBool>,
reporter: Arc<dyn SessionReporter>,
Expand Down Expand Up @@ -432,6 +439,7 @@ fn walker_worker(
const CHECK_INTERVAL: usize = 1024;
let mut n = 0;
let search_directories = inner.search_directories.clone();
let include_dirs = inner.include_dirs;
let injector = injector.clone();
let cancelled = inner.cancelled.clone();
let shutdown = inner.shutdown.clone();
Expand All @@ -441,16 +449,24 @@ fn walker_worker(
Ok(entry) => entry,
Err(_) => return ignore::WalkState::Continue,
};
if entry.file_type().is_some_and(|ft| ft.is_dir()) {
let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
if is_dir && !include_dirs {
return ignore::WalkState::Continue;
}
let path = entry.path();
let Some(full_path) = path.to_str() else {
return ignore::WalkState::Continue;
};
if let Some((_, relative_path)) = get_file_path(path, &search_directories) {
if relative_path.is_empty() {
return ignore::WalkState::Continue;
}
injector.push(Arc::from(full_path), |_, cols| {
cols[0] = Utf32String::from(relative_path);
cols[0] = if is_dir {
Utf32String::from(format!("{relative_path}/"))
} else {
Utf32String::from(relative_path)
};
});
}
n += 1;
Expand Down Expand Up @@ -549,6 +565,7 @@ fn matcher_worker(
score: match_.score,
path: PathBuf::from(relative_path),
root: inner.search_directories[root_idx].clone(),
is_dir: Path::new(full_path).is_dir(),
indices,
})
})
Expand Down Expand Up @@ -895,6 +912,7 @@ mod tests {
threads: NonZero::new(2).unwrap(),
compute_indices: false,
respect_gitignore: true,
include_dirs: false,
};
let results =
run("file-000", vec![dir.path().to_path_buf()], options, None).expect("run ok");
Expand Down
227 changes: 158 additions & 69 deletions codex-rs/tui/src/bottom_pane/chat_composer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1306,53 +1306,58 @@ impl ChatComposer {
return (InputResult::None, true);
};

let sel_path = sel.to_string_lossy().to_string();
// If selected path looks like an image (png/jpeg), attach as image instead of inserting text.
let is_image = Self::is_image_path(&sel_path);
if is_image {
// Determine dimensions; if that fails fall back to normal path insertion.
let path_buf = PathBuf::from(&sel_path);
match image::image_dimensions(&path_buf) {
Ok((width, height)) => {
tracing::debug!("selected image dimensions={}x{}", width, height);
// Remove the current @token (mirror logic from insert_selected_path without inserting text)
// using the flat text and byte-offset cursor API.
let cursor_offset = self.textarea.cursor();
let text = self.textarea.text();
// Clamp to a valid char boundary to avoid panics when slicing.
let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset);
let before_cursor = &text[..safe_cursor];
let after_cursor = &text[safe_cursor..];

// Determine token boundaries in the full text.
let start_idx = before_cursor
.char_indices()
.rfind(|(_, c)| c.is_whitespace())
.map(|(idx, c)| idx + c.len_utf8())
.unwrap_or(0);
let end_rel_idx = after_cursor
.char_indices()
.find(|(_, c)| c.is_whitespace())
.map(|(idx, _)| idx)
.unwrap_or(after_cursor.len());
let end_idx = safe_cursor + end_rel_idx;

self.textarea.replace_range(start_idx..end_idx, "");
self.textarea.set_cursor(start_idx);

self.attach_image(path_buf);
// Add a trailing space to keep typing fluid.
self.textarea.insert_str(" ");
}
Err(err) => {
tracing::trace!("image dimensions lookup failed: {err}");
// Fallback to plain path insertion if metadata read fails.
self.insert_selected_path(&sel_path);
let sel_path = sel.path.to_string_lossy().to_string();

if sel.is_dir {
self.insert_selected_dir_mention(&sel_path);
} else {
// If selected path looks like an image (png/jpeg), attach as image instead of inserting text.
let is_image = Self::is_image_path(&sel_path);
if is_image {
// Determine dimensions; if that fails fall back to normal path insertion.
let path_buf = PathBuf::from(&sel_path);
match image::image_dimensions(&path_buf) {
Ok((width, height)) => {
tracing::debug!("selected image dimensions={}x{}", width, height);
// Remove the current @token (mirror logic from insert_selected_path without inserting text)
// using the flat text and byte-offset cursor API.
let cursor_offset = self.textarea.cursor();
let text = self.textarea.text();
// Clamp to a valid char boundary to avoid panics when slicing.
let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset);
let before_cursor = &text[..safe_cursor];
let after_cursor = &text[safe_cursor..];

// Determine token boundaries in the full text.
let start_idx = before_cursor
.char_indices()
.rfind(|(_, c)| c.is_whitespace())
.map(|(idx, c)| idx + c.len_utf8())
.unwrap_or(0);
let end_rel_idx = after_cursor
.char_indices()
.find(|(_, c)| c.is_whitespace())
.map(|(idx, _)| idx)
.unwrap_or(after_cursor.len());
let end_idx = safe_cursor + end_rel_idx;

self.textarea.replace_range(start_idx..end_idx, "");
self.textarea.set_cursor(start_idx);

self.attach_image(path_buf);
// Add a trailing space to keep typing fluid.
self.textarea.insert_str(" ");
}
Err(err) => {
tracing::trace!("image dimensions lookup failed: {err}");
// Fallback to plain path insertion if metadata read fails.
self.insert_selected_path(&sel_path);
}
}
} else {
// Non-image: inserting file path.
self.insert_selected_path(&sel_path);
}
} else {
// Non-image: inserting file path.
self.insert_selected_path(&sel_path);
}
// No selection: treat Enter as closing the popup/session.
self.active_popup = ActivePopup::None;
Expand Down Expand Up @@ -1430,7 +1435,7 @@ impl ChatComposer {
if let Some(path) = path.as_deref() {
self.record_mention_path(&insert_text, path);
}
self.insert_selected_mention(&insert_text);
self.insert_selected_file_mention(&insert_text);
}
self.active_popup = ActivePopup::None;
}
Expand Down Expand Up @@ -1723,21 +1728,18 @@ impl ChatComposer {
path.to_string()
};

// Replace the slice `[start_idx, end_idx)` with the chosen path and a trailing space.
let mut new_text =
String::with_capacity(text.len() - (end_idx - start_idx) + inserted.len() + 1);
new_text.push_str(&text[..start_idx]);
new_text.push_str(&inserted);
new_text.push(' ');
new_text.push_str(&text[end_idx..]);

// Path replacement is plain text; rebuild without carrying elements.
self.textarea.set_text_clearing_elements(&new_text);
let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1);
// Preserve existing elements (e.g. prior mentions) outside the replaced token.
let mut replacement = String::with_capacity(inserted.len() + 1);
replacement.push_str(&inserted);
replacement.push(' ');
self.textarea
.replace_range(start_idx..end_idx, &replacement);
let new_cursor = start_idx.saturating_add(replacement.len());
self.textarea.set_cursor(new_cursor);
}

fn insert_selected_mention(&mut self, insert_text: &str) {
// Inserts the selected file mention's insert_text at the current @token position.
fn insert_selected_file_mention(&mut self, insert_text: &str) {
let cursor_offset = self.textarea.cursor();
let text = self.textarea.text();
let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset);
Expand All @@ -1758,19 +1760,47 @@ impl ChatComposer {
.unwrap_or(after_cursor.len());
let end_idx = safe_cursor + end_rel_idx;

let inserted = insert_text.to_string();
// Preserve existing elements (e.g. prior folder mentions) outside the replaced token.
let mut replacement = String::with_capacity(insert_text.len() + 1);
replacement.push_str(insert_text);
replacement.push(' ');
self.textarea
.replace_range(start_idx..end_idx, &replacement);
let new_cursor = start_idx.saturating_add(replacement.len());
self.textarea.set_cursor(new_cursor);
}

let mut new_text =
String::with_capacity(text.len() - (end_idx - start_idx) + inserted.len() + 1);
new_text.push_str(&text[..start_idx]);
new_text.push_str(&inserted);
new_text.push(' ');
new_text.push_str(&text[end_idx..]);
// Inserts the selected directory mention's insert_text at the current @token position.
fn insert_selected_dir_mention(&mut self, path: &str) {
let cursor_offset = self.textarea.cursor();
let text = self.textarea.text();
let safe_cursor = Self::clamp_to_char_boundary(text, cursor_offset);

// Mention insertion rebuilds plain text, so drop existing elements.
self.textarea.set_text_clearing_elements(&new_text);
let new_cursor = start_idx.saturating_add(inserted.len()).saturating_add(1);
self.textarea.set_cursor(new_cursor);
let before_cursor = &text[..safe_cursor];
let after_cursor = &text[safe_cursor..];

let start_idx = before_cursor
.char_indices()
.rfind(|(_, c)| c.is_whitespace())
.map(|(idx, c)| idx + c.len_utf8())
.unwrap_or(0);

let end_rel_idx = after_cursor
.char_indices()
.find(|(_, c)| c.is_whitespace())
.map(|(idx, _)| idx)
.unwrap_or(after_cursor.len());
let end_idx = safe_cursor + end_rel_idx;

self.textarea.replace_range(start_idx..end_idx, "");
self.textarea.set_cursor(start_idx);

let mut dir_path = path.to_string();
if !dir_path.ends_with('/') {
dir_path.push('/');
}
self.textarea.insert_element(&dir_path);
self.textarea.insert_str(" ");
}

fn record_mention_path(&mut self, insert_text: &str, path: &str) {
Expand Down Expand Up @@ -4738,6 +4768,65 @@ mod tests {
assert_eq!(composer.textarea.text(), "@");
}

#[test]
fn selecting_file_after_folder_preserves_folder_text_element() {
use crossterm::event::KeyCode;
use crossterm::event::KeyEvent;
use crossterm::event::KeyModifiers;

let (tx, _rx) = unbounded_channel::<AppEvent>();
let sender = AppEventSender::new(tx);
let mut composer = ChatComposer::new(
true,
sender,
false,
"Ask Codex to do anything".to_string(),
false,
);

composer.insert_str("@doc");
composer.on_file_search_result(
"doc".to_string(),
vec![FileMatch {
score: 100,
path: PathBuf::from("docs"),
root: PathBuf::from("/repo"),
is_dir: true,
indices: None,
}],
);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));

let elements_after_dir = composer.text_elements();
assert_eq!(composer.textarea.text(), "docs/ ");
assert_eq!(elements_after_dir.len(), 1);
assert_eq!(
elements_after_dir[0].placeholder(composer.textarea.text()),
Some("docs/")
);

composer.insert_str("@main");
composer.on_file_search_result(
"main".to_string(),
vec![FileMatch {
score: 99,
path: PathBuf::from("main.rs"),
root: PathBuf::from("/repo"),
is_dir: false,
indices: None,
}],
);
let _ = composer.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));

let elements_after_file = composer.text_elements();
assert_eq!(composer.textarea.text(), "docs/ main.rs ");
assert_eq!(elements_after_file.len(), 1);
assert_eq!(
elements_after_file[0].placeholder(composer.textarea.text()),
Some("docs/"),
);
}

/// Behavior: multiple paste operations can coexist; placeholders should be expanded to their
/// original content on submission.
#[test]
Expand Down
Loading
Loading