From 7b0d59785ca2e0e69c8506a03897ebfb1b8c0b60 Mon Sep 17 00:00:00 2001 From: Jeremy Soller Date: Wed, 29 Nov 2023 14:33:17 -0700 Subject: [PATCH] Implement project search in context drawer --- Cargo.lock | 142 ++++++++++++++++++++++++++++- Cargo.toml | 4 +- i18n/en/cosmic_edit.ftl | 6 +- src/config.rs | 5 + src/icon_cache.rs | 2 + src/localize.rs | 2 + src/main.rs | 196 +++++++++++++++++++++++++++++++++++++--- src/menu.rs | 4 + src/mime_icon.rs | 2 + src/search.rs | 121 +++++++++++++++++++++++++ 10 files changed, 469 insertions(+), 15 deletions(-) create mode 100644 src/search.rs diff --git a/Cargo.lock b/Cargo.lock index aeffae8..4a0708f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -630,6 +630,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "bstr" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "542f33a8835a0884b006a0c3df3dadd99c0c3f296ed26c2fdc8028e01ad6230c" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.14.0" @@ -982,8 +993,10 @@ dependencies = [ "cosmic-text 0.10.0", "env_logger", "fork", + "grep", "i18n-embed", "i18n-embed-fl", + "ignore", "lazy_static", "libcosmic", "log", @@ -1020,7 +1033,7 @@ dependencies = [ [[package]] name = "cosmic-text" version = "0.10.0" -source = "git+https://github.com/pop-os/cosmic-text#cbd567d2387d1d9803c8f056fab12e66fcf8f044" +source = "git+https://github.com/pop-os/cosmic-text#daa5a6615c52d352e9c87d30e1ab35b8dd14bd91" dependencies = [ "cosmic_undo_2", "fontdb 0.16.0", @@ -1500,6 +1513,24 @@ version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a26ae43d7bcc3b814de94796a5e736d4029efb0ee900c12e2d54c993ad1a1e07" +[[package]] +name = "encoding_rs" +version = "0.8.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7268b386296a025e474d5140678f75d6de9493ae55a5d709eeb9dd08149945e1" +dependencies = [ + "cfg-if 1.0.0", +] + +[[package]] +name = "encoding_rs_io" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83" +dependencies = [ + "encoding_rs", +] + [[package]] name = "enumflags2" version = "0.7.8" @@ -2151,6 +2182,19 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2fabcfbdc87f4758337ca535fb41a6d701b65693ce38287d856d1674551ec9b" +[[package]] +name = "globset" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57da3b9b5b85bd66f31093f8c408b90a74431672542466497dcbdfdc02034be1" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax 0.8.2", +] + [[package]] name = "glow" version = "0.12.3" @@ -2238,6 +2282,85 @@ dependencies = [ "bitflags 2.4.1", ] +[[package]] +name = "grep" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2b024ec1e686cb64d78beb852030b0e632af93817f1ed25be0173af0e94939" +dependencies = [ + "grep-cli", + "grep-matcher", + "grep-printer", + "grep-regex", + "grep-searcher", +] + +[[package]] +name = "grep-cli" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea40788c059ab8b622c4d074732750bfb3bd2912e2dd58eabc11798a4d5ad725" +dependencies = [ + "bstr", + "globset", + "libc", + "log", + "termcolor", + "winapi-util", +] + +[[package]] +name = "grep-matcher" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a3141a10a43acfedc7c98a60a834d7ba00dfe7bec9071cbfc19b55b292ac02" +dependencies = [ + "memchr", +] + +[[package]] +name = "grep-printer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743c12a03c8aee38b6e5bd0168d8ebb09345751323df4a01c56e792b1f38ceb2" +dependencies = [ + "bstr", + "grep-matcher", + "grep-searcher", + "log", + "serde", + "serde_json", + "termcolor", +] + +[[package]] +name = "grep-regex" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f748bb135ca835da5cbc67ca0e6955f968db9c5df74ca4f56b18e1ddbc68230d" +dependencies = [ + "bstr", + "grep-matcher", + "log", + "regex-automata", + "regex-syntax 0.8.2", +] + +[[package]] +name = "grep-searcher" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba536ae4f69bec62d8839584dd3153d3028ef31bb229f04e09fb5a9e5a193c54" +dependencies = [ + "bstr", + "encoding_rs", + "encoding_rs_io", + "grep-matcher", + "log", + "memchr", + "memmap2 0.9.0", +] + [[package]] name = "grid" version = "0.11.0" @@ -2605,6 +2728,22 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "ignore" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "747ad1b4ae841a78e8aba0d63adbfbeaea26b517b63705d47856b73015d27060" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "image" version = "0.23.14" @@ -2890,7 +3029,6 @@ dependencies = [ "iced_runtime", "iced_style", "iced_tiny_skia", - "iced_wgpu", "iced_widget", "iced_winit", "lazy_static", diff --git a/Cargo.toml b/Cargo.toml index 97e52a0..166c7e1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,8 @@ license = "GPL-3.0-only" [dependencies] env_logger = "0.10.0" +grep = "0.3.1" +ignore = "0.4.21" lazy_static = "1.4.0" log = "0.4.20" notify = "6.1.1" @@ -30,7 +32,7 @@ features = ["syntect", "vi"] [dependencies.libcosmic] git = "https://github.com/pop-os/libcosmic" default-features = false -features = ["tokio", "winit", "wgpu"] +features = ["tokio", "winit"] #path = "../libcosmic" #TODO: clean up and send changes upstream diff --git a/i18n/en/cosmic_edit.ftl b/i18n/en/cosmic_edit.ftl index 4522521..47d1217 100644 --- a/i18n/en/cosmic_edit.ftl +++ b/i18n/en/cosmic_edit.ftl @@ -11,6 +11,9 @@ character-count = Characters character-count-no-spaces = Characters (without spaces) line-count = Lines +## Project search +project-search = Project search + ## Settings settings = Settings @@ -57,9 +60,10 @@ redo = Redo cut = Cut copy = Copy paste = Paste -select-all = Select All +select-all = Select all find = Find replace = Replace +find-in-project = Find in project... spell-check = Spell check... ## View diff --git a/src/config.rs b/src/config.rs index c781c44..8492c3d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: GPL-3.0-only + use cosmic::{ cosmic_config::{self, cosmic_config_derive::CosmicConfigEntry, CosmicConfigEntry}, iced::keyboard::{KeyCode, Modifiers}, @@ -26,6 +28,7 @@ pub enum Action { Redo, Save, SelectAll, + ToggleProjectSearch, ToggleSettingsPage, ToggleWordWrap, Undo, @@ -47,6 +50,7 @@ impl Action { Self::Redo => Message::Redo, Self::Save => Message::Save, Self::SelectAll => Message::SelectAll, + Self::ToggleProjectSearch => Message::ToggleContextPage(ContextPage::ProjectSearch), Self::ToggleSettingsPage => Message::ToggleContextPage(ContextPage::Settings), Self::ToggleWordWrap => Message::ToggleWordWrap, Self::Undo => Message::Undo, @@ -114,6 +118,7 @@ impl KeyBind { bind!([Ctrl, Shift], Z, Redo); bind!([Ctrl], S, Save); bind!([Ctrl], A, SelectAll); + bind!([Ctrl, Shift], F, ToggleProjectSearch); bind!([Ctrl], Comma, ToggleSettingsPage); bind!([Alt], Z, ToggleWordWrap); bind!([Ctrl], Z, Undo); diff --git a/src/icon_cache.rs b/src/icon_cache.rs index 2a5a637..2008b76 100644 --- a/src/icon_cache.rs +++ b/src/icon_cache.rs @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: GPL-3.0-only + use cosmic::widget::icon; use std::collections::HashMap; diff --git a/src/localize.rs b/src/localize.rs index 1111142..f5c6fb9 100644 --- a/src/localize.rs +++ b/src/localize.rs @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: GPL-3.0-only + use i18n_embed::{ fluent::{fluent_language_loader, FluentLanguageLoader}, DefaultLocalizer, LanguageLoader, Localizer, diff --git a/src/main.rs b/src/main.rs index 4294dd7..aa702c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ use cosmic::{ app::{message, Command, Core, Settings}, cosmic_config::{self, CosmicConfigEntry}, cosmic_theme, executor, + font::Font, iced::{ clipboard, event, futures::{self, SinkExt}, @@ -42,6 +43,9 @@ mod menu; use self::project::ProjectNode; mod project; +use self::search::ProjectSearchResult; +mod search; + use self::tab::Tab; mod tab; @@ -164,8 +168,12 @@ pub enum Message { OpenFile(PathBuf), OpenProjectDialog, OpenProject(PathBuf), + OpenSearchResult(usize, usize), Paste, PasteValue(String), + ProjectSearchResult(ProjectSearchResult), + ProjectSearchSubmit, + ProjectSearchValue(String), Quit, Redo, Save, @@ -177,6 +185,7 @@ pub enum Message { TabClose(segmented_button::Entity), TabContextAction(segmented_button::Entity, Action), TabContextMenu(segmented_button::Entity, Option), + TabSetCursor(segmented_button::Entity, Cursor), TabWidth(u16), Todo, ToggleAutoIndent, @@ -189,6 +198,8 @@ pub enum Message { #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ContextPage { DocumentStatistics, + //TODO: Move search to pop-up + ProjectSearch, Settings, } @@ -196,6 +207,7 @@ impl ContextPage { fn title(&self) -> String { match self { Self::DocumentStatistics => fl!("document-statistics"), + Self::ProjectSearch => fl!("project-search"), Self::Settings => fl!("settings"), } } @@ -213,6 +225,8 @@ pub struct App { font_sizes: Vec, theme_names: Vec, context_page: ContextPage, + project_search_value: String, + project_search_result: Option, watcher_opt: Option, } @@ -316,14 +330,14 @@ impl App { self.open_folder(&path, position + 1, 1); } - pub fn open_tab(&mut self, path_opt: Option) { + pub fn open_tab(&mut self, path_opt: Option) -> Option { let tab = match path_opt { Some(path) => { let canonical = match fs::canonicalize(&path) { Ok(ok) => ok, Err(err) => { log::error!("failed to canonicalize {:?}: {}", path, err); - return; + return None; } }; @@ -342,7 +356,7 @@ impl App { } if let Some(entity) = activate_opt { self.tab_model.activate(entity); - return; + return Some(entity); } let mut tab = Tab::new(&self.config); @@ -353,13 +367,16 @@ impl App { None => Tab::new(&self.config), }; - self.tab_model - .insert() - .text(tab.title()) - .icon(tab.icon(16)) - .data::(tab) - .closable() - .activate(); + Some( + self.tab_model + .insert() + .text(tab.title()) + .icon(tab.icon(16)) + .data::(tab) + .closable() + .activate() + .id(), + ) } fn update_config(&mut self) -> Command { @@ -531,6 +548,8 @@ impl Application for App { font_sizes, theme_names, context_page: ContextPage::Settings, + project_search_value: String::new(), + project_search_result: None, watcher_opt: None, }; @@ -844,6 +863,43 @@ impl Application for App { Message::OpenProject(path) => { self.open_project(path); } + Message::OpenSearchResult(file_i, line_i) => { + let path_cursor_opt = match &self.project_search_result { + Some(project_search_result) => match project_search_result.files.get(file_i) { + Some(file_search_result) => match file_search_result.lines.get(line_i) { + Some(line_search_result) => Some(( + file_search_result.path.to_path_buf(), + Cursor::new( + line_search_result.number.saturating_sub(1), + line_search_result.first.start(), + ), + )), + None => { + log::warn!("failed to find search result {}, {}", file_i, line_i); + None + } + }, + None => { + log::warn!("failed to find search result {}", file_i); + None + } + }, + None => None, + }; + + if let Some((path, cursor)) = path_cursor_opt { + if let Some(entity) = self.open_tab(Some(path)) { + return Command::batch([ + //TODO: why must this be done in a command? + Command::perform( + async move { message::app(Message::TabSetCursor(entity, cursor)) }, + |x| x, + ), + self.update_tab(), + ]); + } + } + } Message::Paste => { return clipboard::read(|value_opt| match value_opt { Some(value) => message::app(Message::PasteValue(value)), @@ -857,6 +913,51 @@ impl Application for App { } None => {} }, + Message::ProjectSearchResult(project_search_result) => { + self.project_search_result = Some(project_search_result); + } + Message::ProjectSearchSubmit => { + //TODO: cache projects outside of nav model? + let mut project_paths = Vec::new(); + for id in self.nav_model.iter() { + match self.nav_model.data(id) { + Some(ProjectNode::Folder { path, root, .. }) => { + if *root { + project_paths.push(path.clone()) + } + } + _ => {} + } + } + + let project_search_value = self.project_search_value.clone(); + let mut project_search_result = ProjectSearchResult { + value: project_search_value.clone(), + in_progress: true, + files: Vec::new(), + }; + self.project_search_result = Some(project_search_result.clone()); + return Command::perform( + async move { + let task_res = tokio::task::spawn_blocking(move || { + project_search_result.search_projects(project_paths); + message::app(Message::ProjectSearchResult(project_search_result)) + }) + .await; + match task_res { + Ok(message) => message, + Err(err) => { + log::error!("failed to run search task: {}", err); + message::none() + } + } + }, + |x| x, + ); + } + Message::ProjectSearchValue(value) => { + self.project_search_value = value; + } Message::Quit => { //TODO: prompt for save? return window::close(); @@ -977,6 +1078,13 @@ impl Application for App { None => {} } } + Message::TabSetCursor(entity, cursor) => match self.tab_model.data::(entity) { + Some(tab) => { + let mut editor = tab.editor.lock().unwrap(); + editor.set_cursor(cursor); + } + None => {} + }, Message::TabWidth(tab_width) => { self.config.tab_width = tab_width; return self.save_config(); @@ -1066,6 +1174,72 @@ impl Application for App { .into()]) .into() } + ContextPage::ProjectSearch => { + let search_input = widget::text_input::search_input( + &fl!("project-search"), + &self.project_search_value, + ); + + let items = match &self.project_search_result { + Some(project_search_result) => { + let mut items = + Vec::with_capacity(project_search_result.files.len().saturating_add(1)); + + if project_search_result.in_progress { + items.push(search_input.into()); + } else { + items.push( + search_input + .on_input(Message::ProjectSearchValue) + .on_submit(Message::ProjectSearchSubmit) + .into(), + ); + } + + for (file_i, file_search_result) in + project_search_result.files.iter().enumerate() + { + let mut column = + widget::column::with_capacity(file_search_result.lines.len()); + for (line_i, line_search_result) in + file_search_result.lines.iter().enumerate() + { + column = column.push( + widget::button( + widget::text(format!( + "{}: {}", + line_search_result.number, line_search_result.text + )) + .font(Font::MONOSPACE), + ) + .on_press(Message::OpenSearchResult(file_i, line_i)) + .width(Length::Fill) + .style(theme::Button::AppletMenu), + ); + } + + items.push( + widget::settings::view_section(format!( + "{}", + file_search_result.path.display(), + )) + .add(column) + .into(), + ); + } + + items + } + None => { + vec![search_input + .on_input(Message::ProjectSearchValue) + .on_submit(Message::ProjectSearchSubmit) + .into()] + } + }; + + widget::settings::view_column(items).into() + } ContextPage::Settings => { let app_theme_selected = match self.config.app_theme { AppTheme::Dark => 1, @@ -1222,7 +1396,7 @@ impl Application for App { None => text_box.into(), }; tab_column = tab_column.push(tab_element); - tab_column = tab_column.push(text(status).font(cosmic::font::Font::MONOSPACE)); + tab_column = tab_column.push(text(status).font(Font::MONOSPACE)); } None => { log::warn!("TODO: No tab open"); diff --git a/src/menu.rs b/src/menu.rs index 525a037..80818ee 100644 --- a/src/menu.rs +++ b/src/menu.rs @@ -195,6 +195,10 @@ pub fn menu_bar<'a>(config: &Config) -> Element<'a, Message> { MenuTree::new(horizontal_rule(1)), menu_key(fl!("find"), "Ctrl + F", Message::Todo), menu_key(fl!("replace"), "Ctrl + H", Message::Todo), + menu_item( + fl!("find-in-project"), + Message::ToggleContextPage(ContextPage::ProjectSearch), + ), MenuTree::new(horizontal_rule(1)), menu_item(fl!("spell-check"), Message::Todo), ], diff --git a/src/mime_icon.rs b/src/mime_icon.rs index 58db86c..40fca06 100644 --- a/src/mime_icon.rs +++ b/src/mime_icon.rs @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: GPL-3.0-only + use cosmic::widget::icon; use std::{collections::HashMap, path::Path, sync::Mutex}; diff --git a/src/search.rs b/src/search.rs new file mode 100644 index 0000000..9d9a790 --- /dev/null +++ b/src/search.rs @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: GPL-3.0-only + +use grep::matcher::{Match, Matcher}; +use grep::regex::RegexMatcher; +use grep::searcher::{sinks::UTF8, Searcher}; +use std::path::PathBuf; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct LineSearchResult { + pub number: usize, + pub text: String, + pub first: Match, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FileSearchResult { + pub path: PathBuf, + pub lines: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ProjectSearchResult { + //TODO: should this be included? + pub value: String, + pub in_progress: bool, + pub files: Vec, +} + +impl ProjectSearchResult { + pub fn search_projects(&mut self, project_paths: Vec) { + //TODO: support literal search + //TODO: use ignore::WalkParallel? + match RegexMatcher::new(&self.value) { + Ok(matcher) => { + let mut searcher = Searcher::new(); + let mut walk_builder_opt: Option = None; + for project_path in project_paths.iter() { + walk_builder_opt = match walk_builder_opt.take() { + Some(mut walk_builder) => { + walk_builder.add(project_path); + Some(walk_builder) + } + None => Some(ignore::WalkBuilder::new(project_path)), + }; + } + + if let Some(walk_builder) = walk_builder_opt { + for entry_res in walk_builder.build() { + let entry = match entry_res { + Ok(ok) => ok, + Err(err) => { + log::error!("failed to walk projects {:?}: {}", project_paths, err); + continue; + } + }; + + match entry.file_type() { + Some(file_type) => { + if file_type.is_dir() { + continue; + } + } + None => {} + } + + let entry_path = entry.path(); + + let mut lines = Vec::new(); + match searcher.search_path( + &matcher, + &entry_path, + UTF8(|number_u64, text| { + match usize::try_from(number_u64) { + Ok(number) => match matcher.find(text.as_bytes()) { + Ok(Some(first)) => { + lines.push(LineSearchResult { + number, + text: text.to_string(), + first, + }); + }, + Ok(None) => { + log::error!("first match in file {:?} line {} not found", entry_path, number); + } + Err(err) => { + log::error!("failed to find first match in file {:?} line {}: {}", entry_path, number, err); + } + }, + Err(err) => { + log::error!("failed to convert file {:?} line {} to usize: {}", entry_path, number_u64, err); + } + } + Ok(true) + }), + ) { + Ok(()) => { + if !lines.is_empty() { + self.files.push(FileSearchResult { + path: entry_path.to_path_buf(), + lines, + }); + } + } + Err(err) => { + log::error!("failed to search file {:?}: {}", entry_path, err); + } + } + } + } + } + Err(err) => { + log::error!( + "failed to create regex matcher with value {:?}: {}", + self.value, + err + ); + } + } + self.in_progress = false; + } +}