Added project search

This commit is contained in:
Joshua Barretto 2025-09-23 16:35:34 +01:00
parent b0b4de2f64
commit d13982cc05
7 changed files with 263 additions and 39 deletions

View file

@ -36,12 +36,14 @@ pub enum Action {
OpenOpener(PathBuf), // Open the file opener
OpenFinder(Option<String>), // Open the finder, with the given default query
SwitchBuffer(BufferId), // Switch the current pane to the given buffer
OpenFile(PathBuf), // Open the file and switch the current pane to it
CommandStart(&'static str), // Start a new command
GotoLine(isize), // Go to the specified file line
SelectToken, // Fully select the token under the cursor
SelectAll, // Fully select the entire input
Save, // Save the current buffer
OpenFile(PathBuf, usize), // Open the file (on the given line) and switch the current pane to it
CommandStart(&'static str), // Start a new command
GotoLine(isize), // Go to the specified file line
BeginSearch(String), // Request to begin a search with the given needle
OpenSearcher(PathBuf, String), // Start a project-wide search with the given location and needle
SelectToken, // Fully select the token under the cursor
SelectAll, // Fully select the entire input
Save, // Save the current buffer
Mouse(MouseAction, [isize; 2], bool), // (action, pos, is_ctrl)
Undo,
Redo,
@ -306,7 +308,7 @@ impl RawEvent {
}
}
pub fn to_open_opener(&self, path: PathBuf) -> Option<Action> {
pub fn to_open_opener(&self, path: &PathBuf) -> Option<Action> {
if matches!(
&self.0,
TerminalEvent::Key(KeyEvent {
@ -316,7 +318,7 @@ impl RawEvent {
..
})
) {
Some(Action::OpenOpener(path))
Some(Action::OpenOpener(path.clone()))
} else {
None
}
@ -349,6 +351,16 @@ impl RawEvent {
})
) {
Some(Action::CommandStart("goto_line"))
} else if matches!(
&self.0,
TerminalEvent::Key(KeyEvent {
code: KeyCode::Char('f'),
modifiers,
kind: KeyEventKind::Press,
..
}) if *modifiers == KeyModifiers::CONTROL | KeyModifiers::SHIFT
) {
Some(Action::CommandStart("search"))
} else {
None
}

View file

@ -10,7 +10,7 @@ pub struct Doc {
// Remember the cursor we use for each buffer
cursors: HashMap<BufferId, CursorId>,
input: Input,
search: Option<Search>,
finder: Option<Finder>,
}
impl Doc {
@ -22,7 +22,7 @@ impl Doc {
.into_iter()
.collect(),
input: Input::default(),
search: None,
finder: None,
}
}
@ -53,10 +53,10 @@ impl Element for Doc {
fn handle(&mut self, state: &mut State, event: Event) -> Result<Resp, Event> {
let cursor_id = self.cursors[&self.buffer];
if let Some(search) = &mut self.search {
let resp = search.handle(state, &mut self.input, self.buffer, cursor_id, event)?;
if let Some(finder) = &mut self.finder {
let resp = finder.handle(state, &mut self.input, self.buffer, cursor_id, event)?;
if resp.is_end() {
self.search = None;
self.finder = None;
}
return Ok(Resp::handled(resp.event));
}
@ -72,15 +72,16 @@ impl Element for Doc {
match event.to_action(|e| {
e.to_open_switcher()
.or_else(|| e.to_open_opener(open_path))
.or_else(|| e.to_open_opener(&open_path))
.or_else(|| e.to_open_finder(None))
.or_else(|| e.to_move())
.or_else(|| e.to_save())
}) {
action @ Some(Action::OpenSwitcher) => Ok(Resp::handled(action.map(Into::into))),
action @ Some(Action::OpenOpener(_)) => Ok(Resp::handled(action.map(Into::into))),
action @ Some(Action::OpenSwitcher) | action @ Some(Action::OpenOpener(_)) => {
Ok(Resp::handled(action.map(Into::into)))
}
ref action @ Some(Action::OpenFinder(ref query)) => {
self.search = Some(Search::new(
self.finder = Some(Finder::new(
buffer.cursors[cursor_id],
query.clone(),
state,
@ -90,13 +91,27 @@ impl Element for Doc {
));
Ok(Resp::handled(None))
}
Some(Action::BeginSearch(needle)) => {
let path = buffer
.path
.clone()
.unwrap_or_else(|| std::env::current_dir().expect("no cwd"));
Ok(Resp::handled(Some(
Action::OpenSearcher(path, needle).into(),
)))
}
Some(Action::SwitchBuffer(new_buffer)) => {
self.switch_buffer(state, new_buffer);
Ok(Resp::handled(None))
}
Some(Action::OpenFile(path)) => match state.open_or_get(path) {
Some(Action::OpenFile(path, line_idx)) => match state.open_or_get(path) {
Ok(buffer_id) => {
self.switch_buffer(state, buffer_id);
if let Some(buffer) = state.buffers.get_mut(self.buffer) {
let cursor_id = self.cursors[&self.buffer];
buffer.goto_cursor(cursor_id, [0, line_idx as isize], true);
self.input.refocus(buffer, cursor_id);
}
Ok(Resp::handled(None))
}
Err(err) => Ok(Resp::handled(Some(
@ -126,40 +141,40 @@ impl Visual for Doc {
};
let cursor_id = self.cursors[&self.buffer];
let search_h = if self.search.is_some() { 3 } else { 0 };
let finder_h = if self.finder.is_some() { 3 } else { 0 };
// Render input
frame
.rect(
[0, 0],
[frame.size()[0], frame.size()[1].saturating_sub(search_h)],
[frame.size()[0], frame.size()[1].saturating_sub(finder_h)],
)
.with_focus(true /*self.search.is_none()*/)
.with_focus(true /*self.finder.is_none()*/)
.with(|f| {
self.input.render(
state,
buffer.name().as_deref(),
buffer,
cursor_id,
self.search.as_ref(),
self.finder.as_ref(),
f,
)
});
// Render search
if let Some(search) = &mut self.search {
// Render finder
if let Some(finder) = &mut self.finder {
frame
.rect(
[0, frame.size()[1].saturating_sub(search_h)],
[frame.size()[0], search_h],
[0, frame.size()[1].saturating_sub(finder_h)],
[frame.size()[0], finder_h],
)
.with_focus(true)
.with(|f| search.render(state, f));
.with(|f| finder.render(state, f));
}
}
}
pub struct Search {
pub struct Finder {
old_cursor: Cursor,
buffer: Buffer,
@ -171,7 +186,7 @@ pub struct Search {
results: Vec<usize>,
}
impl Search {
impl Finder {
fn new(
old_cursor: Cursor,
query: Option<String>,
@ -288,7 +303,7 @@ impl Search {
}
}
impl Visual for Search {
impl Visual for Finder {
fn render(&mut self, state: &State, frame: &mut Rect) {
let title = format!("{} of {} results", self.selected + 1, self.results.len());
self.input.render(

View file

@ -38,8 +38,9 @@ impl Input {
pub fn focus(&mut self, coord: [isize; 2]) {
for i in 0..2 {
self.focus[i] =
self.focus[i].clamp(coord[i] - self.last_area.size()[i] as isize + 1, coord[i]);
self.focus[i] = self.focus[i]
.max(coord[i] - self.last_area.size()[i] as isize + 1)
.min(coord[i]);
}
}
@ -207,7 +208,7 @@ impl Input {
title: Option<&str>,
buffer: &Buffer,
cursor_id: CursorId,
search: Option<&Search>,
finder: Option<&Finder>,
frame: &mut Rect,
) {
// Add frame
@ -296,7 +297,7 @@ impl Input {
}
None => (Color::Reset, ' '),
};
let bg = match search.map(|s| s.contains(pos?)) {
let bg = match finder.map(|s| s.contains(pos?)) {
Some(Some(true)) => state.theme.select_bg,
Some(Some(false)) => state.theme.search_result_bg,
Some(None) if line_selected && frame.has_focus() => {

View file

@ -3,14 +3,16 @@ mod input;
mod panes;
mod prompt;
mod root;
mod search;
mod status;
pub use self::{
doc::{Doc, Search},
doc::{Doc, Finder},
input::Input,
panes::{Pane, Panes},
prompt::{Confirm, Opener, Prompt, Show, Switcher},
root::Root,
search::Searcher,
status::Status,
};
@ -156,10 +158,10 @@ impl<T: Clone> Element<T> for Options<T> {
Some(Action::Move(dir, Dist::Char, false, false)) => {
match dir {
Dir::Up => {
self.selected =
(self.selected + self.ranking.len() - 1) % self.ranking.len()
self.selected = (self.selected + self.ranking.len()).saturating_sub(1)
% self.ranking.len().max(1)
}
Dir::Down => self.selected = (self.selected + 1) % self.ranking.len(),
Dir::Down => self.selected = (self.selected + 1) % self.ranking.len().max(1),
_ => return Err(event),
}
Ok(Resp::handled(None))

View file

@ -54,6 +54,10 @@ impl Prompt {
- 1;
Ok(Action::GotoLine(line))
}
Some("search") => {
let needle = args.next().ok_or_else(|| "Expected argument".to_string())?;
Ok(Action::BeginSearch(needle.to_string()))
}
Some(cmd) => Err(format!("Unknown command `{cmd}`")),
None => Err(format!("No command entered")),
}
@ -397,7 +401,7 @@ impl Element<()> for Opener {
self.set_string(&format!("{}/", file.path.display()));
Ok(Resp::handled(None))
}
Ok(Some(file)) => Ok(Resp::end(Some(Action::OpenFile(file.path).into()))),
Ok(Some(file)) => Ok(Resp::end(Some(Action::OpenFile(file.path, 0).into()))),
Ok(None) => Ok(Resp::handled(None)),
Err(event) => {
let res = self

View file

@ -13,6 +13,7 @@ pub enum Task {
Confirm(Confirm),
Switcher(Switcher),
Opener(Opener),
Searcher(Searcher),
}
impl Task {
@ -23,6 +24,7 @@ impl Task {
Self::Confirm(c) => c.requested_height(),
Self::Switcher(s) => s.requested_height(),
Self::Opener(o) => o.requested_height(),
Self::Searcher(s) => s.requested_height(),
}
}
}
@ -58,6 +60,7 @@ impl Element<()> for Root {
Task::Confirm(c) => c.handle(state, event),
Task::Switcher(s) => s.handle(state, event),
Task::Opener(o) => o.handle(state, event),
Task::Searcher(s) => s.handle(state, event),
};
match res {
@ -98,6 +101,10 @@ impl Element<()> for Root {
self.tasks.clear(); // Overrides all
self.tasks.push(Task::Opener(Opener::new(path)));
}
Action::OpenSearcher(path, needle) => {
self.tasks.clear(); // Overrides all
self.tasks.push(Task::Searcher(Searcher::new(path, needle)));
}
Action::CommandStart(cmd) => {
self.tasks.clear(); // Prompt overrides all
self.tasks
@ -158,6 +165,7 @@ impl Visual for Root {
Task::Confirm(c) => c.render(state, frame),
Task::Switcher(s) => s.render(state, frame),
Task::Opener(o) => o.render(state, frame),
Task::Searcher(s) => s.render(state, frame),
});
}

182
src/ui/search.rs Normal file
View file

@ -0,0 +1,182 @@
use super::*;
use crate::state::{Buffer, CursorId};
use std::fs;
pub struct Searcher {
options: Options<SearchResult>,
path: PathBuf,
needle: String,
// Filter
buffer: Buffer,
cursor_id: CursorId,
input: Input,
}
impl Searcher {
pub fn new(mut path: PathBuf, needle: String) -> Self {
let path = loop {
if let Ok(mut entries) = fs::read_dir(&path)
&& entries.any(|e| {
e.map_or(false, |e| {
e.file_name() == ".git" && e.file_type().map_or(false, |t| t.is_dir())
})
})
{
break path;
} else if !path.pop() {
break std::env::current_dir().expect("No cwd");
}
};
fn search_in(path: &PathBuf, needle: &str, results: &mut Vec<SearchResult>) {
// Cap reached!
if results.len() < 500 {
// Skip hidden files
if path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("")
.starts_with(".")
{
return;
}
if let Ok(file) = fs::File::open(path)
&& let Ok(md) = file.metadata()
// Maximum 1 MB
&& md.len() < 1 << 20
&& let Ok(s) = fs::read_to_string(path)
{
for (line_idx, line_text) in
s.lines().enumerate().filter(|(_, l)| l.contains(needle))
{
results.push(SearchResult {
path: path.clone(),
line_idx,
line_text: line_text.trim().to_string(),
});
}
} else if let Ok(entries) = fs::read_dir(path) {
// Special case, ignore Rust target dir to prevent searching too many places
{
let mut path = path.clone();
path.push("CACHEDIR.TAG");
if path.exists() {
return;
}
}
for entry in entries {
let Ok(entry) = entry else { continue };
search_in(&entry.path(), needle, results);
}
}
}
}
let mut results = Vec::new();
search_in(&path, &needle, &mut results);
let mut buffer = Buffer::default();
let cursor_id = buffer.start_session();
Self {
options: Options::new(results),
path,
needle,
cursor_id,
buffer,
input: Input::filter(),
}
}
pub fn requested_height(&self) -> usize {
self.options.requested_height() + 3
}
fn update_completions(&mut self) {
let filter = self.buffer.text.to_string().to_lowercase();
self.options.apply_scoring(|e| {
let name = format!("{}", e.path.display()).to_lowercase();
if name == filter {
Some((0, name.chars().count()))
} else if name.starts_with(&filter) {
Some((1, name.chars().count()))
} else if name.contains(&filter) {
Some((2, name.chars().count()))
} else {
None
}
});
}
}
impl Element<()> for Searcher {
fn handle(&mut self, state: &mut State, event: Event) -> Result<Resp<()>, Event> {
match event.to_action(|e| e.to_cancel().or_else(|| e.to_char().map(Action::Char))) {
Some(Action::Cancel) => Ok(Resp::end(None)),
_ => match self.options.handle(state, event).map(Resp::into_ended) {
// Selecting a directory enters the directory
Ok(Some(result)) => Ok(Resp::end(Some(Event::Action(Action::OpenFile(
result.path,
result.line_idx,
))))),
Ok(None) => Ok(Resp::handled(None)),
Err(event) => {
let res = self
.input
.handle(&mut self.buffer, self.cursor_id, event)
.map(Resp::into_can_end);
self.update_completions();
res
}
},
}
}
}
#[derive(Clone)]
pub struct SearchResult {
pub path: PathBuf,
pub line_idx: usize,
pub line_text: String,
}
impl Visual for SearchResult {
fn render(&mut self, state: &State, frame: &mut Rect) {
let name = match self.path.file_name().and_then(|n| n.to_str()) {
Some(name) => format!("{name}"),
None => format!("Unknown"),
};
frame
.with_fg(state.theme.option_file)
.text([0, 0], &format!("{name}:{}", self.line_idx + 1));
frame.with_fg(state.theme.margin_line_num).with(|f| {
f.text(
[f.size()[0] as isize / 3, 0],
&format!("{}", self.line_text),
);
});
}
}
impl Visual for Searcher {
fn render(&mut self, state: &State, frame: &mut Rect) {
frame
.rect([0, 0], [frame.size()[0], frame.size()[1].saturating_sub(3)])
.with(|f| self.options.render(state, f));
frame
.rect([0, frame.size()[1].saturating_sub(3)], [frame.size()[0], 3])
.with(|f| {
let title = format!(
"{} of {} results for '{}' in {}/",
self.options.selected + 1,
self.options.ranking.len(),
self.needle,
self.path.display()
);
self.input
.render(state, Some(&title), &self.buffer, self.cursor_id, None, f)
});
}
}