From 0531ee0a5f7c8b0e5782e8032a6efc18f11cfad0 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 29 May 2026 14:26:22 +0900 Subject: [PATCH 1/9] reduce Arc call --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/search.rs | 116 +++++++++++++++++++++++++------------------------- 3 files changed, 59 insertions(+), 61 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4357652..2a61cf1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2438,7 +2438,7 @@ checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" [[package]] name = "lexi" -version = "0.2.1" +version = "0.2.2" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 6ab3f19..76e93cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "lexi" -version = "0.2.1" +version = "0.2.2" edition = "2024" [dependencies] diff --git a/src/search.rs b/src/search.rs index 7df26b7..9636f4a 100644 --- a/src/search.rs +++ b/src/search.rs @@ -55,14 +55,14 @@ impl searcher::SinkError for SearchError { /// The Sink is the "callback" object. /// It gets called whenever a match is found in a file. -struct SearchSink<'a, 'm> { +struct SearchSink<'a> { /// Accumulates results found during the scan of a single file. results: &'a mut Vec, /// The matcher used to find the exact byte offsets of multiple terms. - matcher: &'m RegexMatcher, + matcher: &'a RegexMatcher, } -impl searcher::Sink for SearchSink<'_, '_> { +impl searcher::Sink for SearchSink<'_> { type Error = SearchError; /// Called by the searcher when a line matches the regex. @@ -343,82 +343,79 @@ pub fn spawn_search(config: &SearchConfig) -> Result { }; let path = entry.path(); - let path_text: Arc = path.to_string_lossy().into(); // First pass: check if the path itself matches the query. let mut path_matches = Vec::new(); - let mut at = 0; - while let Ok(Some(m)) = matcher.find_at(path_text.as_bytes(), at) { - path_matches.push((m.start(), m.end())); - at = m.end(); + if let Some(path_str) = path.to_str() { + let mut at = 0; + while let Ok(Some(m)) = matcher.find_at(path_str.as_bytes(), at) { + path_matches.push((m.start(), m.end())); + at = m.end(); + } } // Second pass: scan file content (unless "File name only" mode is on). let mut entries = Vec::new(); if mode != SearchMode::FileNameOnly { let mut handled = false; - let extension = path - .extension() - .and_then(|ext| ext.to_str()) - .unwrap_or("") - .to_lowercase(); - - if mode == SearchMode::IncludeDocContent { - match extension.as_str() { - "docx" | "pptx" | "xlsx" | "doc" | "ppt" | "xls" => { - if let Ok(text) = office_oxide::extract_text(path) { + + if mode == SearchMode::IncludeDocContent + && let Some(ext) = path.extension().and_then(|e| e.to_str()) + { + if ext.eq_ignore_ascii_case("docx") + || ext.eq_ignore_ascii_case("xlsx") + || ext.eq_ignore_ascii_case("pptx") + || ext.eq_ignore_ascii_case("doc") + || ext.eq_ignore_ascii_case("ppt") + || ext.eq_ignore_ascii_case("xls") + { + if let Ok(text) = office_oxide::extract_text(path) { + let mut sink = SearchSink { + results: &mut entries, + matcher: &matcher, + }; + let _ = + searcher.search_slice(&*matcher, text.as_bytes(), &mut sink); + handled = true; + } + } else if ext.eq_ignore_ascii_case("pdf") { + if let Ok(doc) = pdf_oxide::PdfDocument::open(path) { + let mut full_pdf_text = String::new(); + let mut page = 0; + while let Ok(page_text) = doc.extract_text(page) { + if page_text.is_empty() && page > 0 { + break; + } + full_pdf_text.push_str(&page_text); + full_pdf_text.push('\n'); + page += 1; + } + if !full_pdf_text.is_empty() { let mut sink = SearchSink { results: &mut entries, matcher: &matcher, }; let _ = searcher.search_slice( &*matcher, - text.as_bytes(), + full_pdf_text.as_bytes(), &mut sink, ); handled = true; } } - "pdf" => { - if let Ok(doc) = pdf_oxide::PdfDocument::open(path) { - let mut full_pdf_text = String::new(); - let mut page = 0; - while let Ok(page_text) = doc.extract_text(page) { - full_pdf_text.push_str(&page_text); - full_pdf_text.push('\n'); - page += 1; - } - if !full_pdf_text.is_empty() { - let mut sink = SearchSink { - results: &mut entries, - matcher: &matcher, - }; - let _ = searcher.search_slice( - &*matcher, - full_pdf_text.as_bytes(), - &mut sink, - ); - handled = true; - } - } - } - "eml" => { - // Korean .eml files often use Quoted-Printable encoding for the body. - if let Ok(content) = std::fs::read(path) - && let Ok(decoded) = quoted_printable::decode( - &content, - quoted_printable::ParseMode::Robust, - ) - { - let mut sink = SearchSink { - results: &mut entries, - matcher: &matcher, - }; - let _ = searcher.search_slice(&*matcher, &decoded, &mut sink); - handled = true; - } - } - _ => {} + } else if ext.eq_ignore_ascii_case("eml") + && let Ok(content) = std::fs::read(path) + && let Ok(decoded) = quoted_printable::decode( + &content, + quoted_printable::ParseMode::Robust, + ) + { + let mut sink = SearchSink { + results: &mut entries, + matcher: &matcher, + }; + let _ = searcher.search_slice(&*matcher, &decoded, &mut sink); + handled = true; } } @@ -434,6 +431,7 @@ pub fn spawn_search(config: &SearchConfig) -> Result { // If anything matched (name or content), send it to the UI. if !entries.is_empty() || !path_matches.is_empty() { + let path_text: Arc = path.to_string_lossy().into(); let modified_at = entry.metadata().ok().and_then(|m| m.modified().ok()); let _ = tx.send(SearchResult { path: path_text, From 19669c826bf05a907eea653103c04afb44af0465 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 29 May 2026 14:40:49 +0900 Subject: [PATCH 2/9] line optimize --- src/search.rs | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/search.rs b/src/search.rs index 9636f4a..abf2dd4 100644 --- a/src/search.rs +++ b/src/search.rs @@ -81,7 +81,7 @@ impl searcher::Sink for SearchSink<'_> { // Logic for handling extremely long lines (like log files or minified JS). // Center the view around the first match to keep the UI snappy. - const MAX_LINE_LENGTH: usize = 1024; + const MAX_LINE_LENGTH: usize = 256; let (display_text, display_matches) = if bytes.len() > MAX_LINE_LENGTH { if let Some(&(m_start, _)) = all_matches.first() { // Calculate a window around the first match. @@ -102,13 +102,19 @@ impl searcher::Sink for SearchSink<'_> { window_end -= 1; } + let has_leading = window_start > 0; + let has_trailing = window_end < bytes.len(); + let estimated_cap = (window_end - window_start) + + 3 * has_leading as usize + + 3 * has_trailing as usize; + // Truncate the window to fit within MAX_LINE_LENGTH, preserving character boundaries. - let mut truncated = String::new(); - if window_start > 0 { + let mut truncated = String::with_capacity(estimated_cap); + if has_leading { truncated.push_str("..."); } truncated.push_str(&String::from_utf8_lossy(&bytes[window_start..window_end])); - if window_end < bytes.len() { + if has_trailing { truncated.push_str("..."); } @@ -126,7 +132,8 @@ impl searcher::Sink for SearchSink<'_> { while end > 0 && end < bytes.len() && (bytes[end] & 0xC0) == 0x80 { end -= 1; } - let mut truncated = String::from_utf8_lossy(&bytes[..end]).into_owned(); + let mut truncated = String::with_capacity(end + 3); + truncated.push_str(&String::from_utf8_lossy(&bytes[..end])); truncated.push_str("..."); (truncated.into(), Vec::new().into()) } From 742e7703be25121b05246fd45845562648eaf152 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 29 May 2026 15:20:38 +0900 Subject: [PATCH 3/9] revert matcher --- src/search.rs | 83 ++++++++++++++++++++++++++------------------------- 1 file changed, 43 insertions(+), 40 deletions(-) diff --git a/src/search.rs b/src/search.rs index abf2dd4..d933c6b 100644 --- a/src/search.rs +++ b/src/search.rs @@ -369,35 +369,35 @@ pub fn spawn_search(config: &SearchConfig) -> Result { if mode == SearchMode::IncludeDocContent && let Some(ext) = path.extension().and_then(|e| e.to_str()) { - if ext.eq_ignore_ascii_case("docx") - || ext.eq_ignore_ascii_case("xlsx") - || ext.eq_ignore_ascii_case("pptx") - || ext.eq_ignore_ascii_case("doc") - || ext.eq_ignore_ascii_case("ppt") - || ext.eq_ignore_ascii_case("xls") - { - if let Ok(text) = office_oxide::extract_text(path) { - let mut sink = SearchSink { - results: &mut entries, - matcher: &matcher, - }; - let _ = - searcher.search_slice(&*matcher, text.as_bytes(), &mut sink); - handled = true; + let ext_lower = ext.to_ascii_lowercase(); + match ext_lower.as_str() { + "docx" | "xlsx" | "pptx" | "doc" | "ppt" | "xls" => { + if let Ok(text) = office_oxide::extract_text(path) { + let mut sink = SearchSink { + results: &mut entries, + matcher: &matcher, + }; + let _ = searcher.search_slice( + &*matcher, + text.as_bytes(), + &mut sink, + ); + handled = true; + } } - } else if ext.eq_ignore_ascii_case("pdf") { - if let Ok(doc) = pdf_oxide::PdfDocument::open(path) { - let mut full_pdf_text = String::new(); - let mut page = 0; - while let Ok(page_text) = doc.extract_text(page) { - if page_text.is_empty() && page > 0 { - break; + "pdf" => { + if let Ok(doc) = pdf_oxide::PdfDocument::open(path) { + let mut full_pdf_text = String::new(); + + if let Ok(total_pages) = doc.page_count() { + for page in 0..total_pages { + if let Ok(page_text) = doc.extract_text(page) { + full_pdf_text.push_str(&page_text); + full_pdf_text.push('\n'); + } + } } - full_pdf_text.push_str(&page_text); - full_pdf_text.push('\n'); - page += 1; - } - if !full_pdf_text.is_empty() { + let mut sink = SearchSink { results: &mut entries, matcher: &matcher, @@ -410,19 +410,22 @@ pub fn spawn_search(config: &SearchConfig) -> Result { handled = true; } } - } else if ext.eq_ignore_ascii_case("eml") - && let Ok(content) = std::fs::read(path) - && let Ok(decoded) = quoted_printable::decode( - &content, - quoted_printable::ParseMode::Robust, - ) - { - let mut sink = SearchSink { - results: &mut entries, - matcher: &matcher, - }; - let _ = searcher.search_slice(&*matcher, &decoded, &mut sink); - handled = true; + "eml" => { + if let Ok(content) = std::fs::read(path) + && let Ok(decoded) = quoted_printable::decode( + &content, + quoted_printable::ParseMode::Robust, + ) + { + let mut sink = SearchSink { + results: &mut entries, + matcher: &matcher, + }; + let _ = searcher.search_slice(&*matcher, &decoded, &mut sink); + handled = true; + } + } + _ => {} } } From 7829f776a27b39cace1b97d3d5d821ba40f9e092 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 29 May 2026 17:13:40 +0900 Subject: [PATCH 4/9] skip system path --- src/app.rs | 77 +++++++++++----------- src/search.rs | 176 +++++++++++++++++++++++++++++++++++--------------- 2 files changed, 159 insertions(+), 94 deletions(-) diff --git a/src/app.rs b/src/app.rs index 5c4fa70..1ae7d0e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -426,9 +426,15 @@ impl SearchApp { /// Renders the inputs for paths, patterns, and search terms. fn draw_search_controls(&mut self, ui: &mut egui::Ui, tab_index: usize) -> bool { - let mut input_changed = false; let tab = &mut self.tabs[tab_index]; + // Store old values to detect changes. + let old_patterns = tab.config.patterns.clone(); + let old_mode = tab.config.mode; + let old_queries: Vec = tab.config.queries.iter().map(|q| q.query.clone()).collect(); + let old_paths_len = tab.config.paths.len(); + let mut path_removed_or_picked = false; + ui.vertical(|ui| { // Path chips management. ui.horizontal_top(|ui| { @@ -470,12 +476,12 @@ impl SearchApp { // Remove path chip. if let Some(i) = path_to_remove { tab.config.paths.remove(i); - input_changed = true; + path_removed_or_picked = true; } // Add path picker. if open_picker && Self::pick_paths(&mut tab.config) { - input_changed = true; + path_removed_or_picked = true; } }); }); @@ -486,47 +492,33 @@ impl SearchApp { ui.horizontal(|ui| { ui.label("파일패턴:"); let remaining_width = ui.available_width() - 300.0; - if ui - .add( - egui::TextEdit::singleline(&mut tab.config.patterns) - .desired_width(remaining_width) - .hint_text("예시: *.pdf (PDF 파일만 검색) *.{pdf,csv} (PDF, CSV 파일만 검색) !dir/ (dir 폴더 제외)"), - ) - .changed() - { - input_changed = true; - } + ui.add( + egui::TextEdit::singleline(&mut tab.config.patterns) + .desired_width(remaining_width) + .hint_text("예시: *.pdf (PDF 파일만 검색) *.{pdf,csv} (PDF, CSV 파일만 검색) !dir/ (dir 폴더 제외)"), + ); let combo_id = ui.id().with("search_mode_combo").with(tab_index); - let combo_res = egui::ComboBox::from_id_salt(combo_id) + egui::ComboBox::from_id_salt(combo_id) .selected_text(tab.config.mode.label()) .width(180.0) .show_ui(ui, |ui| { - let mut sub_changed = false; - - sub_changed |= ui.selectable_value( + ui.selectable_value( &mut tab.config.mode, SearchMode::FileNameOnly, SearchMode::FileNameOnly.label() - ).changed(); - - sub_changed |= ui.selectable_value( + ); + ui.selectable_value( &mut tab.config.mode, SearchMode::PathAndContent, SearchMode::PathAndContent.label() - ).changed(); - - sub_changed |= ui.selectable_value( + ); + ui.selectable_value( &mut tab.config.mode, SearchMode::IncludeDocContent, SearchMode::IncludeDocContent.label() - ).changed(); - - sub_changed + ); }); - if let Some(true) = combo_res.inner { - input_changed = true; - } }); ui.add_space(5.0); @@ -537,16 +529,11 @@ impl SearchApp { ui.horizontal(|ui| { ui.label("검색어:"); let remaining_width = ui.available_width() - 300.0; - if ui - .add( - egui::TextEdit::singleline(&mut query.query) - .desired_width(remaining_width) - .hint_text("예시: text"), - ) - .changed() - { - input_changed = true; - } + ui.add( + egui::TextEdit::singleline(&mut query.query) + .desired_width(remaining_width) + .hint_text("예시: text"), + ); if ui .button("검색중지") .on_hover_text("검색을 중지합니다. (Esc)") @@ -562,7 +549,13 @@ impl SearchApp { } }); - input_changed + let new_queries: Vec = tab.config.queries.iter().map(|q| q.query.clone()).collect(); + + path_removed_or_picked + || old_patterns != tab.config.patterns + || old_mode != tab.config.mode + || old_queries != new_queries + || old_paths_len != tab.config.paths.len() } /// Renders the bottom status bar with search stats and timing. @@ -578,6 +571,8 @@ impl SearchApp { ui.label("대기중: "); } else if tab.file_searched > 0 { ui.colored_label(egui::Color32::DARK_GREEN, "✅ 완료: "); + } else if tab.results.len() >= 10000 { + ui.colored_label(egui::Color32::RED, "결과가 많아 일부만 출력합니다."); } let duration = tab.search_duration(); @@ -629,7 +624,7 @@ impl SearchApp { }); }) .body(|body| { - body.rows(text_height, tab.results.len(), |mut row| { + body.rows(text_height, 10000.min(tab.results.len()), |mut row| { let row_index = row.index(); let entry = &tab.results[row_index]; diff --git a/src/search.rs b/src/search.rs index d933c6b..f8892ea 100644 --- a/src/search.rs +++ b/src/search.rs @@ -346,6 +346,23 @@ pub fn spawn_search(config: &SearchConfig) -> Result { let entry = match result { Ok(e) if e.file_type().map(|ft| ft.is_file()).unwrap_or(false) => e, + Ok(e) if e.file_type().map(|ft| ft.is_dir()).unwrap_or(false) => { + if let Some(path_str) = e.path().to_str() { + let path_lower = path_str.to_lowercase(); + if path_lower.contains(":\\windows") + || path_lower.contains("program files") + || path_lower.contains("appdata\\local\\temp") + || path_lower.contains("\\.git") + { + return WalkState::Skip; + } + } + return WalkState::Continue; + } + Err(walk_err) => { + log::warn!("Skipping entry: {}", walk_err); + return WalkState::Continue; + } _ => return WalkState::Continue, }; @@ -363,16 +380,28 @@ pub fn spawn_search(config: &SearchConfig) -> Result { // Second pass: scan file content (unless "File name only" mode is on). let mut entries = Vec::new(); - if mode != SearchMode::FileNameOnly { - let mut handled = false; - - if mode == SearchMode::IncludeDocContent - && let Some(ext) = path.extension().and_then(|e| e.to_str()) - { - let ext_lower = ext.to_ascii_lowercase(); - match ext_lower.as_str() { - "docx" | "xlsx" | "pptx" | "doc" | "ppt" | "xls" => { - if let Ok(text) = office_oxide::extract_text(path) { + if mode == SearchMode::FileNameOnly { + if !path_matches.is_empty() { + let path_text: Arc = path.to_string_lossy().into(); + let _ = tx.send(SearchResult { + path: path_text, + path_matches: path_matches.into(), + entries, + modified_at: None, + }); + } + return WalkState::Continue; + } + + let mut handled = false; + if mode == SearchMode::IncludeDocContent + && let Some(ext) = path.extension().and_then(|e| e.to_str()) + { + let ext_lower = ext.to_ascii_lowercase(); + match ext_lower.as_str() { + "docx" | "xlsx" | "pptx" | "doc" | "ppt" | "xls" => { + match office_oxide::extract_text(path) { + Ok(text) => { let mut sink = SearchSink { results: &mut entries, matcher: &matcher, @@ -384,58 +413,99 @@ pub fn spawn_search(config: &SearchConfig) -> Result { ); handled = true; } + Err(err) => { + log::warn!( + "Failed to load DOCX/XLSX/PPTX file: {}, error: {}", + path.display(), + err + ); + handled = true; + } } - "pdf" => { - if let Ok(doc) = pdf_oxide::PdfDocument::open(path) { - let mut full_pdf_text = String::new(); - - if let Ok(total_pages) = doc.page_count() { - for page in 0..total_pages { - if let Ok(page_text) = doc.extract_text(page) { - full_pdf_text.push_str(&page_text); - full_pdf_text.push('\n'); - } + } + "pdf" => match pdf_oxide::PdfDocument::open(path) { + Ok(doc) => { + let mut full_pdf_text = String::new(); + + if let Ok(total_pages) = doc.page_count() { + for page in 0..total_pages { + if let Ok(page_text) = doc.extract_text(page) { + full_pdf_text.push_str(&page_text); + full_pdf_text.push('\n'); } } - - let mut sink = SearchSink { - results: &mut entries, - matcher: &matcher, - }; - let _ = searcher.search_slice( - &*matcher, - full_pdf_text.as_bytes(), - &mut sink, - ); - handled = true; } + + let mut sink = SearchSink { + results: &mut entries, + matcher: &matcher, + }; + let _ = searcher.search_slice( + &*matcher, + full_pdf_text.as_bytes(), + &mut sink, + ); + handled = true; } - "eml" => { - if let Ok(content) = std::fs::read(path) - && let Ok(decoded) = quoted_printable::decode( - &content, - quoted_printable::ParseMode::Robust, - ) - { - let mut sink = SearchSink { - results: &mut entries, - matcher: &matcher, - }; - let _ = searcher.search_slice(&*matcher, &decoded, &mut sink); - handled = true; + Err(err) => { + log::warn!( + "Failed to load PDF file: {}, error: {}", + path.display(), + err + ); + handled = true; + } + }, + "eml" => match std::fs::read(path) { + Ok(content) => { + match quoted_printable::decode( + &content, + quoted_printable::ParseMode::Robust, + ) { + Ok(decoded) => { + let mut sink = SearchSink { + results: &mut entries, + matcher: &matcher, + }; + let _ = + searcher.search_slice(&*matcher, &decoded, &mut sink); + handled = true; + } + Err(err) => { + log::warn!( + "Failed to decode EML file: {}, error: {}", + path.display(), + err + ); + handled = true; + } } } - _ => {} - } + Err(err) => { + log::warn!( + "Failed to load EML file: {}, error: {}", + path.display(), + err + ); + handled = true; + } + }, + _ => {} } + } - if !handled { - let sink = SearchSink { - results: &mut entries, - matcher: &matcher, - }; - // The actual heavy lifting: disk I/O and regex scanning. - let _ = searcher.search_path(&*matcher, path, sink); + if !handled { + let sink = SearchSink { + results: &mut entries, + matcher: &matcher, + }; + // The actual heavy lifting: disk I/O and regex scanning. + if let Err(search_err) = searcher.search_path(&*matcher, path, sink) { + log::warn!( + "Failed to search path: {}, error: {:?}", + path.display(), + search_err + ); } } From 195fdc8650e135e58f15833d1c2b8c523ee411f5 Mon Sep 17 00:00:00 2001 From: unknown Date: Fri, 29 May 2026 18:06:25 +0900 Subject: [PATCH 5/9] search optimize --- src/search.rs | 73 +++++++++++++++++++++++++++++++++------------------ 1 file changed, 47 insertions(+), 26 deletions(-) diff --git a/src/search.rs b/src/search.rs index f8892ea..f59d2f6 100644 --- a/src/search.rs +++ b/src/search.rs @@ -60,6 +60,8 @@ struct SearchSink<'a> { results: &'a mut Vec, /// The matcher used to find the exact byte offsets of multiple terms. matcher: &'a RegexMatcher, + /// Atomic flag to signal worker threads to stop early. + quit: Arc, } impl searcher::Sink for SearchSink<'_> { @@ -67,21 +69,30 @@ impl searcher::Sink for SearchSink<'_> { /// Called by the searcher when a line matches the regex. fn matched(&mut self, _searcher: &Searcher, mat: &SinkMatch<'_>) -> Result { + // Check if user cancelled the search even during file scan. + if self.quit.load(Ordering::Relaxed) { + return Ok(false); + } + let line_number = mat.line_number().unwrap_or(0); let bytes = mat.bytes(); // The grep crate tells us the line matches, but not where all the terms are. // Do a second pass here to find all match offsets (for highlighting in UI). + // Limit to 5 matches per line to prevent performance degradation. let mut all_matches = Vec::new(); let mut at = 0; while let Ok(Some(m)) = self.matcher.find_at(bytes, at) { all_matches.push((m.start(), m.end())); at = m.end(); + if all_matches.len() >= 5 { + break; + } } // Logic for handling extremely long lines (like log files or minified JS). // Center the view around the first match to keep the UI snappy. - const MAX_LINE_LENGTH: usize = 256; + const MAX_LINE_LENGTH: usize = 128; let (display_text, display_matches) = if bytes.len() > MAX_LINE_LENGTH { if let Some(&(m_start, _)) = all_matches.first() { // Calculate a window around the first match. @@ -270,11 +281,7 @@ impl SearchConfig { /// Scan for all words in a single pass. fn create_matcher(&self) -> Result { let mut builder = RegexMatcherBuilder::new(); - builder - .case_smart(true) - .case_insensitive(true) - .multi_line(true) - .unicode(true); + builder.case_smart(true).unicode(true); let literals: Vec = self .queries @@ -347,14 +354,14 @@ pub fn spawn_search(config: &SearchConfig) -> Result { let entry = match result { Ok(e) if e.file_type().map(|ft| ft.is_file()).unwrap_or(false) => e, Ok(e) if e.file_type().map(|ft| ft.is_dir()).unwrap_or(false) => { - if let Some(path_str) = e.path().to_str() { - let path_lower = path_str.to_lowercase(); - if path_lower.contains(":\\windows") - || path_lower.contains("program files") - || path_lower.contains("appdata\\local\\temp") - || path_lower.contains("\\.git") - { - return WalkState::Skip; + let path = e.path(); + if let Some(path_str) = path.to_str() { + // Check for system directories without to_lowercase() + if path_str.len() >= 10 { + let prefix = &path_str[..10].to_ascii_lowercase(); + if prefix == "c:\\windows" || prefix == "c:\\program " { + return WalkState::Skip; + } } } return WalkState::Continue; @@ -383,12 +390,17 @@ pub fn spawn_search(config: &SearchConfig) -> Result { if mode == SearchMode::FileNameOnly { if !path_matches.is_empty() { let path_text: Arc = path.to_string_lossy().into(); - let _ = tx.send(SearchResult { - path: path_text, - path_matches: path_matches.into(), - entries, - modified_at: None, - }); + if tx + .send(SearchResult { + path: path_text, + path_matches: path_matches.into(), + entries, + modified_at: None, + }) + .is_err() + { + return WalkState::Quit; + } } return WalkState::Continue; } @@ -405,6 +417,7 @@ pub fn spawn_search(config: &SearchConfig) -> Result { let mut sink = SearchSink { results: &mut entries, matcher: &matcher, + quit: quit.clone(), }; let _ = searcher.search_slice( &*matcher, @@ -439,6 +452,7 @@ pub fn spawn_search(config: &SearchConfig) -> Result { let mut sink = SearchSink { results: &mut entries, matcher: &matcher, + quit: quit.clone(), }; let _ = searcher.search_slice( &*matcher, @@ -466,6 +480,7 @@ pub fn spawn_search(config: &SearchConfig) -> Result { let mut sink = SearchSink { results: &mut entries, matcher: &matcher, + quit: quit.clone(), }; let _ = searcher.search_slice(&*matcher, &decoded, &mut sink); @@ -498,6 +513,7 @@ pub fn spawn_search(config: &SearchConfig) -> Result { let sink = SearchSink { results: &mut entries, matcher: &matcher, + quit: quit.clone(), }; // The actual heavy lifting: disk I/O and regex scanning. if let Err(search_err) = searcher.search_path(&*matcher, path, sink) { @@ -513,12 +529,17 @@ pub fn spawn_search(config: &SearchConfig) -> Result { if !entries.is_empty() || !path_matches.is_empty() { let path_text: Arc = path.to_string_lossy().into(); let modified_at = entry.metadata().ok().and_then(|m| m.modified().ok()); - let _ = tx.send(SearchResult { - path: path_text, - path_matches: path_matches.into(), - entries, - modified_at, - }); + if tx + .send(SearchResult { + path: path_text, + path_matches: path_matches.into(), + entries, + modified_at, + }) + .is_err() + { + return WalkState::Quit; + } } WalkState::Continue }) From e18d5c2607955a7d2c9bdc5b30f83440b9771d60 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 1 Jun 2026 09:35:26 +0900 Subject: [PATCH 6/9] set delayed sort time --- src/app.rs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/app.rs b/src/app.rs index 1ae7d0e..156886d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -143,6 +143,8 @@ pub struct SearchTab { last_input_time: Option, /// Sorting state. sort_by_modified_asc: bool, + /// Last time the results were sorted. + last_sort_time: Instant, } impl Default for SearchTab { @@ -157,6 +159,7 @@ impl Default for SearchTab { error_message: None, last_input_time: None, sort_by_modified_asc: false, + last_sort_time: Instant::now(), } } } @@ -238,13 +241,17 @@ impl SearchTab { } // Convert the raw SearchResult into UI-ready entries. + let had_new_results = !new_results.is_empty(); for result in new_results { self.save_results(ui, result); } - // Sort whenever get new data. - if !is_done || !self.results.is_empty() { + // Sort results only when the search is finished or at most twice per second. + if is_done + || (had_new_results && self.last_sort_time.elapsed() > Duration::from_millis(500)) + { self.sort_results(); + self.last_sort_time = Instant::now(); } // Remove the pending search if we're done. From 42541e8b508233b1451a286b6f0d2079618d31d1 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 1 Jun 2026 09:35:45 +0900 Subject: [PATCH 7/9] set system path to ignore --- src/search.rs | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/src/search.rs b/src/search.rs index f59d2f6..d32bb26 100644 --- a/src/search.rs +++ b/src/search.rs @@ -264,17 +264,21 @@ impl SearchConfig { /// Parses the pattern string (e.g., "*.rs *.md") into a glob override object. pub fn overrides(&self) -> Override { - if self.patterns.is_empty() { - Override::empty() - } else { - let mut builder = OverrideBuilder::new( - std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")), - ); + let mut builder = OverrideBuilder::new("/"); + + // Add default excludes for Windows system directories to improve performance. + if cfg!(target_os = "windows") { + let _ = builder.add("!C:/Windows/**"); + let _ = builder.add("!C:/Program Files/**"); + let _ = builder.add("!C:/Program Files (x86)/**"); + } + + if !self.patterns.is_empty() { for glob in self.patterns.split_whitespace() { let _ = builder.add(glob); } - builder.build().unwrap_or_else(|_| Override::empty()) } + builder.build().unwrap_or_else(|_| Override::empty()) } /// Creates a combined Regex matcher from all search terms. @@ -354,16 +358,6 @@ pub fn spawn_search(config: &SearchConfig) -> Result { let entry = match result { Ok(e) if e.file_type().map(|ft| ft.is_file()).unwrap_or(false) => e, Ok(e) if e.file_type().map(|ft| ft.is_dir()).unwrap_or(false) => { - let path = e.path(); - if let Some(path_str) = path.to_str() { - // Check for system directories without to_lowercase() - if path_str.len() >= 10 { - let prefix = &path_str[..10].to_ascii_lowercase(); - if prefix == "c:\\windows" || prefix == "c:\\program " { - return WalkState::Skip; - } - } - } return WalkState::Continue; } Err(walk_err) => { From 1cfbe74891d5de81ede1508f2f3109663e6fc3be Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 1 Jun 2026 09:48:03 +0900 Subject: [PATCH 8/9] streaming pdf --- src/search.rs | 71 +++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 54 insertions(+), 17 deletions(-) diff --git a/src/search.rs b/src/search.rs index d32bb26..7c4d34d 100644 --- a/src/search.rs +++ b/src/search.rs @@ -432,28 +432,65 @@ pub fn spawn_search(config: &SearchConfig) -> Result { } "pdf" => match pdf_oxide::PdfDocument::open(path) { Ok(doc) => { - let mut full_pdf_text = String::new(); - if let Ok(total_pages) = doc.page_count() { + let path_text: Arc = path.to_string_lossy().into(); + let modified_at = + entry.metadata().ok().and_then(|m| m.modified().ok()); + + let path_matches_arc: Arc<[(usize, usize)]> = + Arc::from(path_matches.as_slice()); + let mut reported_any = false; + for page in 0..total_pages { + if quit.load(Ordering::Relaxed) { + return WalkState::Quit; + } if let Ok(page_text) = doc.extract_text(page) { - full_pdf_text.push_str(&page_text); - full_pdf_text.push('\n'); + let mut page_entries = Vec::new(); + let mut sink = SearchSink { + results: &mut page_entries, + matcher: &matcher, + quit: quit.clone(), + }; + let _ = searcher.search_slice( + &*matcher, + page_text.as_bytes(), + &mut sink, + ); + + if !page_entries.is_empty() { + if tx + .send(SearchResult { + path: Arc::clone(&path_text), + path_matches: Arc::clone(&path_matches_arc), + entries: page_entries, + modified_at, + }) + .is_err() + { + return WalkState::Quit; + } + reported_any = true; + } } } - } - let mut sink = SearchSink { - results: &mut entries, - matcher: &matcher, - quit: quit.clone(), - }; - let _ = searcher.search_slice( - &*matcher, - full_pdf_text.as_bytes(), - &mut sink, - ); - handled = true; + // If the path matched but no content was found, report the path match now. + if !reported_any && !path_matches.is_empty() { + if tx + .send(SearchResult { + path: path_text, + path_matches: path_matches_arc, + entries: Vec::new(), + modified_at, + }) + .is_err() + { + return WalkState::Quit; + } + } + } + return WalkState::Continue; } Err(err) => { log::warn!( @@ -461,7 +498,7 @@ pub fn spawn_search(config: &SearchConfig) -> Result { path.display(), err ); - handled = true; + return WalkState::Continue; } }, "eml" => match std::fs::read(path) { From 5115fce14a8f430e5c85dfbf94d7c63253c045f2 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 1 Jun 2026 09:49:48 +0900 Subject: [PATCH 9/9] make clippy happy --- src/search.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/search.rs b/src/search.rs index 7c4d34d..7ec6c25 100644 --- a/src/search.rs +++ b/src/search.rs @@ -476,8 +476,9 @@ pub fn spawn_search(config: &SearchConfig) -> Result { } // If the path matched but no content was found, report the path match now. - if !reported_any && !path_matches.is_empty() { - if tx + if !reported_any + && !path_matches.is_empty() + && tx .send(SearchResult { path: path_text, path_matches: path_matches_arc, @@ -485,9 +486,8 @@ pub fn spawn_search(config: &SearchConfig) -> Result { modified_at, }) .is_err() - { - return WalkState::Quit; - } + { + return WalkState::Quit; } } return WalkState::Continue;