diff --git a/src/action.rs b/src/action.rs index 8ee2083..d81a063 100644 --- a/src/action.rs +++ b/src/action.rs @@ -36,12 +36,14 @@ pub enum Action { OpenOpener(PathBuf), // Open the file opener OpenFinder(Option), // 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 { + pub fn to_open_opener(&self, path: &PathBuf) -> Option { 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 } diff --git a/src/ui/doc.rs b/src/ui/doc.rs index 422e446..95f8daa 100644 --- a/src/ui/doc.rs +++ b/src/ui/doc.rs @@ -10,7 +10,7 @@ pub struct Doc { // Remember the cursor we use for each buffer cursors: HashMap, input: Input, - search: Option, + finder: Option, } 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 { 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, } -impl Search { +impl Finder { fn new( old_cursor: Cursor, query: Option, @@ -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( diff --git a/src/ui/input.rs b/src/ui/input.rs index a4449dc..22700bd 100644 --- a/src/ui/input.rs +++ b/src/ui/input.rs @@ -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() => { diff --git a/src/ui/mod.rs b/src/ui/mod.rs index f301ed5..765716d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -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 Element for Options { 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)) diff --git a/src/ui/prompt.rs b/src/ui/prompt.rs index be55a22..5e0adc9 100644 --- a/src/ui/prompt.rs +++ b/src/ui/prompt.rs @@ -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 diff --git a/src/ui/root.rs b/src/ui/root.rs index 168ea6c..e6f63e0 100644 --- a/src/ui/root.rs +++ b/src/ui/root.rs @@ -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), }); } diff --git a/src/ui/search.rs b/src/ui/search.rs new file mode 100644 index 0000000..e3a7927 --- /dev/null +++ b/src/ui/search.rs @@ -0,0 +1,182 @@ +use super::*; +use crate::state::{Buffer, CursorId}; +use std::fs; + +pub struct Searcher { + options: Options, + 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) { + // 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, 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) + }); + } +}