Implement project search in context drawer

This commit is contained in:
Jeremy Soller 2023-11-29 14:33:17 -07:00
parent 8996394c75
commit 7b0d59785c
No known key found for this signature in database
GPG key ID: DCFCA852D3906975
10 changed files with 469 additions and 15 deletions

142
Cargo.lock generated
View file

@ -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",

View file

@ -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

View file

@ -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

View file

@ -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);

View file

@ -1,3 +1,5 @@
// SPDX-License-Identifier: GPL-3.0-only
use cosmic::widget::icon;
use std::collections::HashMap;

View file

@ -1,3 +1,5 @@
// SPDX-License-Identifier: GPL-3.0-only
use i18n_embed::{
fluent::{fluent_language_loader, FluentLanguageLoader},
DefaultLocalizer, LanguageLoader, Localizer,

View file

@ -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<Point>),
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<u16>,
theme_names: Vec<String>,
context_page: ContextPage,
project_search_value: String,
project_search_result: Option<ProjectSearchResult>,
watcher_opt: Option<notify::RecommendedWatcher>,
}
@ -316,14 +330,14 @@ impl App {
self.open_folder(&path, position + 1, 1);
}
pub fn open_tab(&mut self, path_opt: Option<PathBuf>) {
pub fn open_tab(&mut self, path_opt: Option<PathBuf>) -> Option<segmented_button::Entity> {
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>(tab)
.closable()
.activate();
Some(
self.tab_model
.insert()
.text(tab.title())
.icon(tab.icon(16))
.data::<Tab>(tab)
.closable()
.activate()
.id(),
)
}
fn update_config(&mut self) -> Command<Message> {
@ -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::<Tab>(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");

View file

@ -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),
],

View file

@ -1,3 +1,5 @@
// SPDX-License-Identifier: GPL-3.0-only
use cosmic::widget::icon;
use std::{collections::HashMap, path::Path, sync::Mutex};

121
src/search.rs Normal file
View file

@ -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<LineSearchResult>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct ProjectSearchResult {
//TODO: should this be included?
pub value: String,
pub in_progress: bool,
pub files: Vec<FileSearchResult>,
}
impl ProjectSearchResult {
pub fn search_projects(&mut self, project_paths: Vec<PathBuf>) {
//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<ignore::WalkBuilder> = 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;
}
}