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/app.rs b/src/app.rs index 5c4fa70..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. @@ -426,9 +433,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 +483,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 +499,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 +536,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 +556,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 +578,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 +631,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 7df26b7..7ec6c25 100644 --- a/src/search.rs +++ b/src/search.rs @@ -55,33 +55,44 @@ 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, + /// Atomic flag to signal worker threads to stop early. + quit: Arc, } -impl searcher::Sink for SearchSink<'_, '_> { +impl searcher::Sink for SearchSink<'_> { type Error = SearchError; /// 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 = 1024; + 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. @@ -102,13 +113,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 +143,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()) } @@ -246,28 +264,28 @@ 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. /// 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 @@ -339,37 +357,61 @@ 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) => { + return WalkState::Continue; + } + Err(walk_err) => { + log::warn!("Skipping entry: {}", walk_err); + return WalkState::Continue; + } _ => return WalkState::Continue, }; 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::FileNameOnly { + if !path_matches.is_empty() { + let path_text: Arc = path.to_string_lossy().into(); + if tx + .send(SearchResult { + path: path_text, + path_matches: path_matches.into(), + entries, + modified_at: None, + }) + .is_err() + { + return WalkState::Quit; + } + } + 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, + quit: quit.clone(), }; let _ = searcher.search_slice( &*matcher, @@ -378,69 +420,157 @@ 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(); - 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; + } + "pdf" => match pdf_oxide::PdfDocument::open(path) { + Ok(doc) => { + 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) { + 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; + } + } + } + + // If the path matched but no content was found, report the path match now. + if !reported_any + && !path_matches.is_empty() + && tx + .send(SearchResult { + path: path_text, + path_matches: path_matches_arc, + entries: Vec::new(), + modified_at, + }) + .is_err() + { + return WalkState::Quit; } - if !full_pdf_text.is_empty() { + } + return WalkState::Continue; + } + Err(err) => { + log::warn!( + "Failed to load PDF file: {}, error: {}", + path.display(), + err + ); + return WalkState::Continue; + } + }, + "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, + quit: quit.clone(), }; - let _ = searcher.search_slice( - &*matcher, - full_pdf_text.as_bytes(), - &mut sink, + 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; } } } - "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; - } + 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, + quit: quit.clone(), + }; + // 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 + ); } } // 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, - 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 })