mirror of
https://github.com/openai/codex.git
synced 2026-04-24 22:54:54 +00:00
tui: complete directories in @ popup
Enable directory matches in codex-file-search sessions via a new include_directories option. The TUI enables it so @ path completion can navigate folders. Directory entries are returned with a trailing path separator, and the composer avoids inserting a trailing space after selecting a directory. Add tests covering directory entries and composer insertion behavior.
This commit is contained in:
@@ -14,6 +14,7 @@ use nucleo::Utf32String;
|
||||
use nucleo::pattern::CaseMatching;
|
||||
use nucleo::pattern::Normalization;
|
||||
use serde::Serialize;
|
||||
use std::borrow::Cow;
|
||||
use std::num::NonZero;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
@@ -86,6 +87,7 @@ pub struct SessionOptions {
|
||||
pub threads: NonZero<usize>,
|
||||
pub compute_indices: bool,
|
||||
pub respect_gitignore: bool,
|
||||
pub include_directories: bool,
|
||||
}
|
||||
|
||||
impl Default for SessionOptions {
|
||||
@@ -98,6 +100,7 @@ impl Default for SessionOptions {
|
||||
threads: NonZero::new(2).unwrap(),
|
||||
compute_indices: false,
|
||||
respect_gitignore: true,
|
||||
include_directories: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,6 +154,7 @@ fn create_session_inner(
|
||||
threads,
|
||||
compute_indices,
|
||||
respect_gitignore,
|
||||
include_directories,
|
||||
} = options;
|
||||
|
||||
let override_matcher = build_override_matcher(search_directory, &exclude)?;
|
||||
@@ -176,6 +180,7 @@ fn create_session_inner(
|
||||
threads: threads.get(),
|
||||
compute_indices,
|
||||
respect_gitignore,
|
||||
include_directories,
|
||||
cancelled: cancelled.clone(),
|
||||
shutdown: Arc::new(AtomicBool::new(false)),
|
||||
reporter,
|
||||
@@ -288,6 +293,7 @@ pub fn run(
|
||||
threads,
|
||||
compute_indices,
|
||||
respect_gitignore,
|
||||
include_directories: false,
|
||||
},
|
||||
reporter.clone(),
|
||||
Some(cancel_flag),
|
||||
@@ -344,6 +350,7 @@ struct SessionInner {
|
||||
threads: usize,
|
||||
compute_indices: bool,
|
||||
respect_gitignore: bool,
|
||||
include_directories: bool,
|
||||
cancelled: Arc<AtomicBool>,
|
||||
shutdown: Arc<AtomicBool>,
|
||||
reporter: Arc<dyn SessionReporter>,
|
||||
@@ -401,20 +408,33 @@ fn walker_worker(
|
||||
|
||||
let walker = walk_builder.build_parallel();
|
||||
|
||||
fn get_file_path<'a>(
|
||||
fn get_entry_path<'a>(
|
||||
entry_result: &'a Result<ignore::DirEntry, ignore::Error>,
|
||||
search_directory: &Path,
|
||||
) -> Option<&'a str> {
|
||||
include_directories: bool,
|
||||
) -> Option<Cow<'a, str>> {
|
||||
let entry = match entry_result {
|
||||
Ok(entry) => entry,
|
||||
Err(_) => return None,
|
||||
};
|
||||
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_directories {
|
||||
return None;
|
||||
}
|
||||
let path = entry.path();
|
||||
match path.strip_prefix(search_directory) {
|
||||
Ok(rel_path) => rel_path.to_str(),
|
||||
Ok(rel_path) => {
|
||||
if rel_path.as_os_str().is_empty() {
|
||||
return None;
|
||||
}
|
||||
let rel_str = rel_path.to_str()?;
|
||||
if is_dir {
|
||||
let mut with_sep = rel_str.to_string();
|
||||
with_sep.push(std::path::MAIN_SEPARATOR);
|
||||
return Some(Cow::Owned(with_sep));
|
||||
}
|
||||
Some(Cow::Borrowed(rel_str))
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
@@ -423,13 +443,14 @@ fn walker_worker(
|
||||
const CHECK_INTERVAL: usize = 1024;
|
||||
let mut n = 0;
|
||||
let search_directory = inner.search_directory.clone();
|
||||
let include_directories = inner.include_directories;
|
||||
let injector = injector.clone();
|
||||
let cancelled = inner.cancelled.clone();
|
||||
let shutdown = inner.shutdown.clone();
|
||||
|
||||
Box::new(move |entry| {
|
||||
if let Some(path) = get_file_path(&entry, &search_directory) {
|
||||
injector.push(Arc::from(path), |path, cols| {
|
||||
if let Some(path) = get_entry_path(&entry, &search_directory, include_directories) {
|
||||
injector.push(Arc::from(path.as_ref()), |path, cols| {
|
||||
cols[0] = Utf32String::from(path.as_ref());
|
||||
});
|
||||
}
|
||||
@@ -649,6 +670,55 @@ mod tests {
|
||||
assert_eq!(file_name_from_path(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_does_not_include_directory_entries_by_default() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir(dir.path().join("src")).unwrap();
|
||||
fs::write(dir.path().join("src").join("main.rs"), "fn main() {}").unwrap();
|
||||
|
||||
let reporter = Arc::new(RecordingReporter::default());
|
||||
let session = create_session(dir.path(), SessionOptions::default(), reporter.clone())
|
||||
.expect("session");
|
||||
|
||||
session.update_query("src");
|
||||
assert!(reporter.wait_for_complete(Duration::from_secs(5)));
|
||||
|
||||
let snapshot = reporter.snapshot();
|
||||
let dir_entry = format!("src{}", std::path::MAIN_SEPARATOR);
|
||||
assert!(
|
||||
!snapshot.matches.iter().any(|m| m.path == dir_entry),
|
||||
"unexpected directory entry in matches: {snapshot:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_includes_directory_entries_when_enabled() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
fs::create_dir(dir.path().join("src")).unwrap();
|
||||
fs::write(dir.path().join("src").join("main.rs"), "fn main() {}").unwrap();
|
||||
|
||||
let reporter = Arc::new(RecordingReporter::default());
|
||||
let session = create_session(
|
||||
dir.path(),
|
||||
SessionOptions {
|
||||
include_directories: true,
|
||||
..SessionOptions::default()
|
||||
},
|
||||
reporter.clone(),
|
||||
)
|
||||
.expect("session");
|
||||
|
||||
session.update_query("src");
|
||||
assert!(reporter.wait_for_complete(Duration::from_secs(5)));
|
||||
|
||||
let snapshot = reporter.snapshot();
|
||||
let dir_entry = format!("src{}", std::path::MAIN_SEPARATOR);
|
||||
assert!(
|
||||
snapshot.matches.iter().any(|m| m.path == dir_entry),
|
||||
"expected directory entry in matches: {snapshot:?}",
|
||||
);
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct RecordingReporter {
|
||||
updates: Mutex<Vec<FileSearchSnapshot>>,
|
||||
|
||||
@@ -1723,17 +1723,28 @@ impl ChatComposer {
|
||||
path.to_string()
|
||||
};
|
||||
|
||||
// Replace the slice `[start_idx, end_idx)` with the chosen path and a trailing space.
|
||||
let ends_with_path_separator = path.ends_with(std::path::MAIN_SEPARATOR)
|
||||
|| path.ends_with('/')
|
||||
|| path.ends_with('\\');
|
||||
let add_trailing_space = !ends_with_path_separator;
|
||||
|
||||
// Replace the slice `[start_idx, end_idx)` with the chosen path.
|
||||
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(' ');
|
||||
if add_trailing_space {
|
||||
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);
|
||||
let new_cursor = if add_trailing_space {
|
||||
start_idx.saturating_add(inserted.len()).saturating_add(1)
|
||||
} else {
|
||||
start_idx.saturating_add(inserted.len())
|
||||
};
|
||||
self.textarea.set_cursor(new_cursor);
|
||||
}
|
||||
|
||||
@@ -3789,6 +3800,33 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_selected_path_adds_space_for_files_but_not_directories() {
|
||||
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.textarea.insert_str("@s");
|
||||
composer.textarea.set_cursor(2);
|
||||
|
||||
composer.insert_selected_path("src/main.rs");
|
||||
assert_eq!(composer.textarea.text(), "src/main.rs ");
|
||||
|
||||
// Simulate completing a directory (paths from file-search include a trailing separator).
|
||||
composer.textarea.set_text_clearing_elements("@s");
|
||||
composer.textarea.set_cursor(2);
|
||||
let dir_path = format!("src{}", std::path::MAIN_SEPARATOR);
|
||||
composer.insert_selected_path(&dir_path);
|
||||
assert_eq!(composer.textarea.text(), dir_path);
|
||||
assert_eq!(composer.textarea.cursor(), dir_path.len());
|
||||
}
|
||||
|
||||
/// Behavior: if the ASCII path has a pending first char (flicker suppression) and a non-ASCII
|
||||
/// char arrives next, the pending ASCII char should still be preserved and the overall input
|
||||
/// should submit normally (i.e. we should not misclassify this as a paste burst).
|
||||
|
||||
@@ -81,6 +81,7 @@ impl FileSearchManager {
|
||||
threads: NUM_FILE_SEARCH_THREADS,
|
||||
compute_indices: true,
|
||||
respect_gitignore: true,
|
||||
include_directories: true,
|
||||
},
|
||||
reporter,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user