diff --git a/assets/app.js b/assets/app.js new file mode 100644 index 0000000..8841630 --- /dev/null +++ b/assets/app.js @@ -0,0 +1,232 @@ +// QuickSearch Application JavaScript + +// Enhanced UI interactions +document.addEventListener('DOMContentLoaded', function() { + console.log('QuickSearch UI loaded'); + + // Add loading states to buttons + function addLoadingState(button, originalText) { + button.disabled = true; + button.innerHTML = '' + originalText; + } + + function removeLoadingState(button, originalText) { + button.disabled = false; + button.innerHTML = originalText; + } + + // Enhanced form interactions + const forms = document.querySelectorAll('form'); + forms.forEach(form => { + form.addEventListener('submit', function(e) { + const submitButton = form.querySelector('button[type="submit"]'); + if (submitButton) { + addLoadingState(submitButton, submitButton.textContent); + } + }); + }); + + // Keyboard shortcuts + document.addEventListener('keydown', function(e) { + // Ctrl+F to focus search + if (e.ctrlKey && e.key === 'f') { + e.preventDefault(); + const searchInput = document.querySelector('input[type="text"]'); + if (searchInput) { + searchInput.focus(); + searchInput.select(); + } + } + + // Escape to clear search + if (e.key === 'Escape') { + const searchInput = document.querySelector('input[type="text"]'); + if (searchInput && searchInput === document.activeElement) { + searchInput.value = ''; + searchInput.blur(); + } + } + }); + + // Enhanced table interactions + function enhanceTable(table) { + // Add click-to-copy functionality for table cells + const cells = table.querySelectorAll('td'); + cells.forEach(cell => { + cell.addEventListener('click', function() { + const text = cell.textContent.trim(); + if (text && navigator.clipboard) { + navigator.clipboard.writeText(text).then(() => { + // Visual feedback + cell.style.backgroundColor = '#4CAF50'; + cell.style.color = 'white'; + setTimeout(() => { + cell.style.backgroundColor = ''; + cell.style.color = ''; + }, 200); + }); + } + }); + }); + + // Add sortable columns (basic implementation) + const headers = table.querySelectorAll('th'); + headers.forEach((header, index) => { + header.style.cursor = 'pointer'; + header.addEventListener('click', () => sortTable(table, index)); + }); + } + + // Simple table sorting + function sortTable(table, columnIndex) { + const tbody = table.querySelector('tbody'); + const rows = Array.from(tbody.querySelectorAll('tr')); + + rows.sort((a, b) => { + const aVal = a.cells[columnIndex]?.textContent.trim() || ''; + const bVal = b.cells[columnIndex]?.textContent.trim() || ''; + + // Try numeric sort first + const aNum = parseFloat(aVal); + const bNum = parseFloat(bVal); + + if (!isNaN(aNum) && !isNaN(bNum)) { + return aNum - bNum; + } + + // Fall back to string sort + return aVal.localeCompare(bVal); + }); + + // Clear tbody and re-append sorted rows + tbody.innerHTML = ''; + rows.forEach(row => tbody.appendChild(row)); + } + + // Auto-enhance any tables that appear + const observer = new MutationObserver(function(mutations) { + mutations.forEach(function(mutation) { + mutation.addedNodes.forEach(function(node) { + if (node.nodeType === 1) { // Element node + const tables = node.querySelectorAll ? node.querySelectorAll('table') : []; + tables.forEach(enhanceTable); + + if (node.tagName === 'TABLE') { + enhanceTable(node); + } + } + }); + }); + }); + + observer.observe(document.body, { childList: true, subtree: true }); + + // Enhance existing tables + document.querySelectorAll('table').forEach(enhanceTable); +}); + +// Utility functions for Rust integration +window.QuickSearch = { + // Function to show toast notifications + showToast: function(message, type = 'info') { + const toast = document.createElement('div'); + toast.className = `toast toast-${type}`; + toast.textContent = message; + toast.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + padding: 12px 20px; + border-radius: 6px; + color: white; + font-weight: 600; + z-index: 2000; + animation: slideIn 0.3s ease; + `; + + // Set background based on type + const colors = { + info: '#2196F3', + success: '#4CAF50', + warning: '#FF9800', + error: '#f44336' + }; + toast.style.backgroundColor = colors[type] || colors.info; + + document.body.appendChild(toast); + + setTimeout(() => { + toast.style.animation = 'slideOut 0.3s ease'; + setTimeout(() => { + document.body.removeChild(toast); + }, 300); + }, 3000); + }, + + // Function to update status display + updateStatus: function(status) { + const statusDisplay = document.querySelector('.status-display'); + if (statusDisplay) { + statusDisplay.textContent = status; + } + }, + + // Function to highlight search terms in results + highlightSearchTerms: function(searchTerm, container) { + if (!searchTerm || !container) return; + + const walker = document.createTreeWalker( + container, + NodeFilter.SHOW_TEXT, + null, + false + ); + + const textNodes = []; + let node; + while (node = walker.nextNode()) { + textNodes.push(node); + } + + textNodes.forEach(textNode => { + const parent = textNode.parentNode; + if (parent.tagName === 'B') return; // Skip already highlighted + + const text = textNode.textContent; + const regex = new RegExp(`(${searchTerm})`, 'gi'); + + if (regex.test(text)) { + const highlightedHTML = text.replace(regex, '$1'); + const wrapper = document.createElement('span'); + wrapper.innerHTML = highlightedHTML; + parent.replaceChild(wrapper, textNode); + } + }); + } +}; + +// Add custom CSS for toasts and animations +const style = document.createElement('style'); +style.textContent = ` + @keyframes slideIn { + from { transform: translateX(100%); opacity: 0; } + to { transform: translateX(0); opacity: 1; } + } + + @keyframes slideOut { + from { transform: translateX(0); opacity: 1; } + to { transform: translateX(100%); opacity: 0; } + } + + mark { + background: #ffeb3b; + padding: 2px 4px; + border-radius: 3px; + font-weight: bold; + } + + .toast { + box-shadow: 0 4px 12px rgba(0,0,0,0.2); + } +`; +document.head.appendChild(style); diff --git a/assets/index.html b/assets/index.html new file mode 100644 index 0000000..5bbae84 --- /dev/null +++ b/assets/index.html @@ -0,0 +1,21 @@ + + + + + + QuickSearch - File Indexer & Search + + + + + + + + + +
+ + + + + diff --git a/assets/styles.css b/assets/styles.css new file mode 100644 index 0000000..3a68b19 --- /dev/null +++ b/assets/styles.css @@ -0,0 +1,299 @@ +/* QuickSearch Application Styles */ +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + margin: 0; + padding: 20px; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + min-height: 100vh; +} + +.app-container { + max-width: 1200px; + margin: 0 auto; + background: white; + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0,0,0,0.1); + overflow: hidden; +} + +.app-header { + background: linear-gradient(90deg, #4CAF50 0%, #45a049 100%); + color: white; + padding: 20px; + text-align: center; +} + +.app-content { + padding: 20px; +} + +.section { + background: #f8f9fa; + border-radius: 8px; + padding: 20px; + margin-bottom: 20px; + border-left: 4px solid #4CAF50; +} + +.form-control { + width: 100%; + padding: 12px; + border: 2px solid #e0e0e0; + border-radius: 6px; + font-size: 14px; + transition: border-color 0.3s ease; + box-sizing: border-box; +} + +.form-control:focus { + outline: none; + border-color: #4CAF50; + box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1); +} + +.btn { + padding: 12px 24px; + border: none; + border-radius: 6px; + cursor: pointer; + font-size: 14px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + transition: all 0.3s ease; + margin-right: 10px; + display: inline-block; +} + +.btn-primary { + background: linear-gradient(90deg, #4CAF50 0%, #45a049 100%); + color: white; +} + +.btn-primary:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(76, 175, 80, 0.3); +} + +.btn-danger { + background: linear-gradient(90deg, #f44336 0%, #d32f2f 100%); + color: white; +} + +.btn-danger:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(244, 67, 54, 0.3); +} + +.btn-info { + background: linear-gradient(90deg, #2196F3 0%, #1976D2 100%); + color: white; +} + +.btn-info:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(33, 150, 243, 0.3); +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; + transform: none !important; + box-shadow: none !important; +} + +.status-display { + background: #1a1a1a; + color: #00ff00; + padding: 15px; + border-radius: 6px; + font-family: 'Courier New', monospace; + white-space: pre-wrap; + font-size: 13px; + border: 1px solid #333; + overflow-x: auto; +} + +.search-results { + background: white; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0,0,0,0.1); + margin-top: 20px; +} + +.results-table { + max-height: 400px; + overflow: auto; + border: 1px solid #ddd; + border-radius: 6px; +} + +.results-table table { + width: 100%; + border-collapse: collapse; +} + +.results-table th { + background: #4CAF50; + color: white; + padding: 12px; + text-align: left; + font-weight: 600; + position: sticky; + top: 0; + border-bottom: 2px solid #45a049; +} + +.results-table td { + padding: 10px 12px; + border-bottom: 1px solid #e0e0e0; + word-break: break-all; +} + +.results-table tbody tr:hover { + background: #f5f5f5; +} + +.results-table tbody tr:nth-child(even) { + background: #fafafa; +} + +.results-table tbody tr:nth-child(even):hover { + background: #f0f0f0; +} + +/* Clickable path cells */ +.path-cell.clickable { + cursor: pointer; + color: #1976d2; + font-weight: 500; + transition: all 0.2s ease; + position: relative; +} + +.path-cell.clickable:hover { + background: #e3f2fd !important; + color: #0d47a1; + text-decoration: underline; +} + +.path-cell.clickable:active { + background: #bbdefb !important; + transform: scale(0.98); +} + + +.error-message { + background: #ffebee; + color: #c62828; + padding: 12px; + border-radius: 6px; + border-left: 4px solid #f44336; + margin: 10px 0; +} + +.modal-backdrop { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0,0,0,0.5); + backdrop-filter: blur(4px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; +} + +.modal-dialog { + background: white; + padding: 30px; + border-radius: 12px; + max-width: 600px; + box-shadow: 0 20px 60px rgba(0,0,0,0.3); + animation: modalSlideIn 0.3s ease; +} + +@keyframes modalSlideIn { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +h1, h2, h3 { + margin-top: 0; + color: #333; +} + +label { + display: block; + margin-bottom: 8px; + font-weight: 600; + color: #555; +} + +.form-group { + margin-bottom: 20px; +} + +/* Highlight matched text in search results */ +.results-table b { + background: #ffeb3b; + padding: 2px 4px; + border-radius: 3px; + font-weight: bold; + color: #333; +} + +/* Loading spinner */ +.loading { + display: inline-block; + width: 20px; + height: 20px; + border: 3px solid #f3f3f3; + border-top: 3px solid #4CAF50; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-right: 10px; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +/* Responsive design */ +@media (max-width: 768px) { + body { + padding: 10px; + } + + .app-container { + border-radius: 8px; + } + + .app-content { + padding: 15px; + } + + .form-control { + font-size: 16px; /* Prevents zoom on iOS */ + } + + .results-table { + font-size: 12px; + } + + .btn { + width: 100%; + margin-bottom: 10px; + margin-right: 0; + } +} diff --git a/index.html b/index.html new file mode 100644 index 0000000..d914d42 --- /dev/null +++ b/index.html @@ -0,0 +1,170 @@ + + + + + + QuickSearch + + + +
+
+

QuickSearch File Indexer

+
+ +
+
+ + diff --git a/src/file_handling.rs b/src/file_handling.rs index dfdcde5..fb54444 100644 --- a/src/file_handling.rs +++ b/src/file_handling.rs @@ -85,7 +85,15 @@ pub fn analyze_files_for_batch_update( }; let fpath = match entry.path().canonicalize() { - Ok(fp) => fp.to_string_lossy().to_string(), + Ok(fp) => { + let path_str = fp.to_string_lossy().to_string(); + // Remove Windows UNC prefix \\?\ + if path_str.starts_with("\\\\?\\") { + path_str[4..].to_string() + } else { + path_str + } + }, Err(_) => continue, }; @@ -205,7 +213,15 @@ pub fn process_batch_updates_files_only( } let fpath = match entry.path().canonicalize() { - Ok(fp) => fp.into_os_string(), + Ok(fp) => { + let path_str = fp.to_string_lossy().to_string(); + // Remove Windows UNC prefix \\?\ + if path_str.starts_with("\\\\?\\") { + std::ffi::OsString::from(&path_str[4..]) + } else { + fp.into_os_string() + } + }, Err(_) => continue, }; @@ -299,7 +315,15 @@ pub fn process_batch_inserts_files_only( } let fpath = match entry.path().canonicalize() { - Ok(fp) => fp.into_os_string(), + Ok(fp) => { + let path_str = fp.to_string_lossy().to_string(); + // Remove Windows UNC prefix \\?\ + if path_str.starts_with("\\\\?\\") { + std::ffi::OsString::from(&path_str[4..]) + } else { + fp.into_os_string() + } + }, Err(_) => continue, }; @@ -377,7 +401,8 @@ pub fn process_text_indexing( // Check stop flag if *stop_flag.lock().unwrap() { - drop(tx); + // Commit current transaction before stopping + let _ = tx.commit(); drop(conn); return Ok(()); } @@ -405,7 +430,7 @@ pub fn process_text_indexing( let trimmed_file_string = safe_truncate_string(&file_string, config.processing.maximum_text_size); Some(trimmed_file_string) } - Err(e) => { + Err(_e) => { // eprintln!("Warning: Failed to read plaintext file {}: {}", fpath, e); None } diff --git a/src/frontend.rs b/src/frontend.rs index 15d9e4f..780c800 100644 --- a/src/frontend.rs +++ b/src/frontend.rs @@ -201,22 +201,25 @@ pub fn App(props: AppProps) -> Element { rsx! { div { - style: "padding: 20px; font-family: Arial, sans-serif;", + class: "app-container", - h1 { "QuickSearch File Indexer" } + div { + class: "app-header", + h1 { "QuickSearch File Indexer" } + } + + div { + class: "app-content", div { - style: "margin-bottom: 20px;", + class: "section", h2 { "Indexing Controls" } div { - style: "margin-bottom: 10px;", - label { - style: "display: block; margin-bottom: 5px;", - "Path to index:" - } + class: "form-group", + label { "Path to index:" } input { - style: "width: 400px; padding: 5px;", + class: "form-control", r#type: "text", value: "{indexing_path}", oninput: move |evt| indexing_path.set(evt.value()) @@ -224,13 +227,10 @@ pub fn App(props: AppProps) -> Element { } div { - style: "margin-bottom: 10px;", - label { - style: "display: block; margin-bottom: 5px;", - "Database path:" - } + class: "form-group", + label { "Database path:" } input { - style: "width: 400px; padding: 5px;", + class: "form-control", r#type: "text", value: "{db_path}", oninput: move |evt| db_path.set(evt.value()) @@ -238,9 +238,9 @@ pub fn App(props: AppProps) -> Element { } div { - style: "margin-bottom: 20px;", + class: "form-group", button { - style: "margin-right: 10px; padding: 10px 20px; background-color: #4CAF50; color: white; border: none; cursor: pointer;", + class: "btn btn-primary", onclick: move |_| { let service = indexing_service_for_start.clone(); let config = config_for_start.clone(); @@ -266,7 +266,7 @@ pub fn App(props: AppProps) -> Element { "Start Indexing" } button { - style: "padding: 10px 20px; background-color: #f44336; color: white; border: none; cursor: pointer;", + class: "btn btn-danger", onclick: move |_| { let _ = indexing_service_for_stop.stop_indexing(); }, @@ -276,9 +276,10 @@ pub fn App(props: AppProps) -> Element { } div { + class: "section", h2 { "Status" } pre { - style: "background-color: #f5f5f5; padding: 10px; border-radius: 5px; font-family: monospace;", + class: "status-display", "{status_text}" } } @@ -287,14 +288,16 @@ pub fn App(props: AppProps) -> Element { indexing_service: props.indexing_service.clone(), db_path: db_path().clone() } + + } // Close app-content } // Configuration validation dialog if show_config_dialog() { div { - style: "position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; z-index: 1000;", + class: "modal-backdrop", div { - style: "background-color: white; padding: 30px; border-radius: 10px; max-width: 600px; box-shadow: 0 4px 6px rgba(0,0,0,0.1);", + class: "modal-dialog", h3 { style: "margin-top: 0; color: #d32f2f;", "⚠️ Configuration Changes Detected" diff --git a/src/indexing.rs b/src/indexing.rs index b003cbb..cb99fc2 100644 --- a/src/indexing.rs +++ b/src/indexing.rs @@ -1,6 +1,7 @@ use std::sync::{Arc, Mutex, mpsc}; use std::thread; use std::time::Instant; +use std::process::Command; use walkdir::WalkDir; use rusqlite::{Connection, params}; @@ -163,10 +164,25 @@ impl IndexingService { /// Execute a search query against the database pub fn execute_search(&self, db_path: &str, query: &str) -> Result, String> { let conn = Connection::open(db_path) - .map_err(|e| format!("Failed to open database: {}", e))?; + .map_err(|e| { + if e.to_string().contains("corrupt") || e.to_string().contains("malformed") { + format!("DATABASE_CORRUPTED: {}", e) + } else { + format!("Failed to open database: {}", e) + } + })?; let mut stmt = conn.prepare(query) - .map_err(|e| format!("Failed to prepare query: {}", e))?; + .map_err(|e| { + let error_msg = e.to_string(); + if error_msg.contains("malformed") || error_msg.contains("corrupt") || error_msg.contains("database disk image is malformed") { + format!("DATABASE_CORRUPTED: {}", error_msg) + } else if error_msg.contains("fts5: syntax error") { + format!("Search syntax error: The search term contains characters that cannot be processed. Please try a simpler search term.") + } else { + format!("Failed to prepare query: {}", error_msg) + } + })?; let column_count = stmt.column_count(); let column_names: Vec = (0..column_count) @@ -187,13 +203,31 @@ impl IndexingService { } Ok(SearchResultRow { values }) }) - .map_err(|e| format!("Failed to execute query: {}", e))?; + .map_err(|e| { + let error_msg = e.to_string(); + if error_msg.contains("malformed") || error_msg.contains("corrupt") || error_msg.contains("database disk image is malformed") { + format!("DATABASE_CORRUPTED: {}", error_msg) + } else if error_msg.contains("fts5: syntax error") { + format!("Search syntax error: The search term contains characters that cannot be processed. Please try a simpler search term.") + } else { + format!("Failed to execute query: {}", error_msg) + } + })?; let mut results = Vec::new(); for row in rows { match row { Ok(search_row) => results.push(search_row), - Err(e) => return Err(format!("Error reading row: {}", e)), + Err(e) => { + let error_msg = e.to_string(); + if error_msg.contains("malformed") || error_msg.contains("corrupt") || error_msg.contains("database disk image is malformed") { + return Err(format!("DATABASE_CORRUPTED: {}", error_msg)); + } else if error_msg.contains("fts5: syntax error") { + return Err(format!("Search syntax error: The search term contains characters that cannot be processed. Please try a simpler search term.")); + } else { + return Err(format!("Error reading row: {}", error_msg)); + } + } } } @@ -203,6 +237,98 @@ impl IndexingService { }]) } + /// Open file explorer to the directory containing the specified file path + pub fn open_file_explorer(&self, file_path: &str) -> Result<(), String> { + #[cfg(windows)] + { + Command::new("explorer") + .arg("/select,") + .arg(file_path) + .spawn() + .map_err(|e| format!("Failed to open file explorer: {}", e))?; + } + + #[cfg(target_os = "macos")] + { + Command::new("open") + .arg("-R") + .arg(file_path) + .spawn() + .map_err(|e| format!("Failed to open file explorer: {}", e))?; + } + + #[cfg(target_os = "linux")] + { + let path = std::path::Path::new(file_path); + let dir_path = if path.is_file() { + path.parent().unwrap_or(path) + } else { + path + }; + + // Try different file managers + let managers = ["xdg-open", "nautilus", "dolphin", "thunar", "pcmanfm"]; + let mut success = false; + + for manager in &managers { + if let Ok(_) = Command::new(manager) + .arg(dir_path) + .spawn() { + success = true; + break; + } + } + + if !success { + return Err("No suitable file manager found".to_string()); + } + } + + Ok(()) + } + + /// Clean up UNC prefixes from existing database entries + pub fn clean_unc_prefixes(&self, db_path: &str) -> Result<(), String> { + let conn = Connection::open(db_path) + .map_err(|e| format!("Failed to open database: {}", e))?; + + // Clean UNC prefixes from files table + conn.execute( + "UPDATE files SET path = SUBSTR(path, 5) WHERE path LIKE '\\\\?\\%'", + (), + ).map_err(|e| format!("Failed to update files table: {}", e))?; + + // Clean UNC prefixes from searchabletext table + conn.execute( + "UPDATE searchabletext SET path = SUBSTR(path, 5) WHERE path LIKE '\\\\?\\%'", + (), + ).map_err(|e| format!("Failed to update searchabletext table: {}", e))?; + + Ok(()) + } + + /// Check if the database is corrupted or malformed + pub fn check_database_health(&self, db_path: &str) -> Result { + match Connection::open(db_path) { + Ok(conn) => { + // Try to run integrity check + match conn.prepare("PRAGMA integrity_check") { + Ok(mut stmt) => { + match stmt.query_row([], |row| { + let result: String = row.get(0)?; + Ok(result == "ok") + }) { + Ok(is_ok) => Ok(is_ok), + Err(_) => Ok(false) + } + }, + Err(_) => Ok(false) + } + }, + Err(_) => Ok(false) + } + } + /// Check if configuration changes require index recreation pub fn check_config_validation(&self, db_path: &str, config: &Config, indexing_path: &str) -> Result>, String> { let conn = Connection::open(db_path) @@ -571,11 +697,19 @@ impl IndexingService { // Critical configuration values that require index recreation let hash_length = config.processing.hash_length.to_string(); let tokenize = config.processing.tokenize.clone(); - let normalized_path = std::path::Path::new(indexing_path) - .canonicalize() - .unwrap_or_else(|_| std::path::PathBuf::from(indexing_path)) - .to_string_lossy() - .to_string(); + let normalized_path = { + let path = std::path::Path::new(indexing_path) + .canonicalize() + .unwrap_or_else(|_| std::path::PathBuf::from(indexing_path)) + .to_string_lossy() + .to_string(); + // Remove Windows UNC prefix \\?\ + if path.starts_with("\\\\?\\") { + path[4..].to_string() + } else { + path + } + }; // Check stored configuration values let mut stored_hash_length: Option = None; @@ -631,11 +765,19 @@ impl IndexingService { fn update_config(conn: &Connection, config: &Config, indexing_path: &str) -> Result<(), String> { let hash_length = config.processing.hash_length.to_string(); let tokenize = config.processing.tokenize.clone(); - let normalized_path = std::path::Path::new(indexing_path) - .canonicalize() - .unwrap_or_else(|_| std::path::PathBuf::from(indexing_path)) - .to_string_lossy() - .to_string(); + let normalized_path = { + let path = std::path::Path::new(indexing_path) + .canonicalize() + .unwrap_or_else(|_| std::path::PathBuf::from(indexing_path)) + .to_string_lossy() + .to_string(); + // Remove Windows UNC prefix \\?\ + if path.starts_with("\\\\?\\") { + path[4..].to_string() + } else { + path + } + }; // Update stored configuration values conn.execute( diff --git a/src/main.rs b/src/main.rs index 5b9b5d3..7fac029 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,5 @@ use std::sync::{Arc, OnceLock}; use dioxus::prelude::*; - mod frontend; mod file_handling; mod document_extraction; @@ -27,9 +26,20 @@ fn main() { std::process::exit(0); }).expect("Error setting Ctrl-C handler"); - launch(app); + LaunchBuilder::desktop() + .with_cfg( + dioxus_desktop::Config::new() + .with_custom_head(format!("", include_str!("../assets/styles.css"))) + .with_window(dioxus_desktop::WindowBuilder::new() + .with_title("QuickSearch - File Indexer & Search") + .with_resizable(true) + .with_inner_size(dioxus_desktop::LogicalSize::new(1000.0, 700.0)) + ) + ) + .launch(app); } + fn app() -> Element { let config = match config::Config::load() { Ok(config) => config, diff --git a/src/search.rs b/src/search.rs index c828c6e..cf972c9 100644 --- a/src/search.rs +++ b/src/search.rs @@ -1,6 +1,7 @@ #![allow(non_snake_case)] use std::sync::Arc; +use std::time::Instant; use dioxus::prelude::*; use crate::indexing::{IndexingService, SearchResult}; @@ -19,21 +20,139 @@ impl PartialEq for SearchProps { pub fn Search(props: SearchProps) -> Element { let mut search_type = use_signal(|| "fulltext".to_string()); let mut search_term = use_signal(|| String::new()); - let mut search_results = use_signal(|| Vec::::new()); + let search_results = use_signal(|| Vec::::new()); let mut search_error = use_signal(|| None::); + let is_searching = use_signal(|| false); + let last_search_time = use_signal(|| None::); + let mut show_corruption_dialog = use_signal(|| false); let service = props.indexing_service.clone(); let db_path = props.db_path.clone(); + // Create a callback to perform search + let perform_search = { + let service = service.clone(); + let db_path = db_path.clone(); + let search_type = search_type.clone(); + let search_term = search_term.clone(); + let search_results = search_results.clone(); + let search_error = search_error.clone(); + let is_searching = is_searching.clone(); + let last_search_time = last_search_time.clone(); + let show_corruption_dialog = show_corruption_dialog.clone(); + + move || { + let service_clone = service.clone(); + let db_clone = db_path.clone(); + let search_type_val = search_type().clone(); + let search_term_val = search_term().clone(); + + let mut search_results_clone = search_results.clone(); + let mut search_error_clone = search_error.clone(); + let mut is_searching_clone = is_searching.clone(); + let mut last_search_time_clone = last_search_time.clone(); + let mut show_corruption_dialog_clone = show_corruption_dialog.clone(); + + spawn(async move { + is_searching_clone.set(true); + search_error_clone.set(None); + last_search_time_clone.set(None); + let start_time = Instant::now(); + + let query = match search_type_val.as_str() { + "fulltext" => { + if search_term_val.trim().is_empty() { + search_error_clone.set(Some("Please enter a search term".to_string())); + is_searching_clone.set(false); + return; + } + + // Sanitize search term for FTS5 by removing problematic characters + let sanitized_term = search_term_val + .replace("'", "''") // Escape single quotes for SQL + .replace(":", " ") // Replace colons with spaces (common in file paths, times, etc.) + .replace(";", " ") // Replace semicolons with spaces + .replace("(", " ") // Replace parentheses with spaces + .replace(")", " ") + .replace("[", " ") // Replace brackets with spaces + .replace("]", " ") + .replace("{", " ") // Replace braces with spaces + .replace("}", " ") + .replace("^", " ") // Replace carets with spaces + .replace("~", " ") // Replace tildes with spaces + .replace("\"", " "); // Replace quotes with spaces to avoid nesting issues + + // Split into words and rejoin to handle multiple spaces and create a proper FTS5 query + let words: Vec<&str> = sanitized_term.split_whitespace().collect(); + if words.is_empty() { + search_error_clone.set(Some("Please enter a valid search term".to_string())); + is_searching_clone.set(false); + return; + } + + // Join words with AND for better matching + let fts_query = words.join(" AND "); + format!("SELECT name, path, snippet(searchabletext, 2, '', '', '...', 64) as snippet FROM searchabletext WHERE text MATCH '{}'", fts_query) + }, + "filename" => { + if search_term_val.trim().is_empty() { + search_error_clone.set(Some("Please enter a filename pattern".to_string())); + is_searching_clone.set(false); + return; + } + format!("SELECT name, path FROM files WHERE name LIKE '%{}%'", search_term_val.replace("'", "''")) + }, + "duplicates" => "SELECT name, count(*) as cnt, path FROM files GROUP BY hash HAVING cnt > 1 ORDER BY cnt DESC".to_string(), + _ => { + is_searching_clone.set(false); + return; + } + }; + + // Run the search in a blocking task to prevent UI freezing + let search_result = tokio::task::spawn_blocking(move || { + service_clone.execute_search(&db_clone, &query) + }).await; + + let elapsed = start_time.elapsed().as_secs_f64(); + match search_result { + Ok(db_result) => { + match db_result { + Ok(results) => { + search_results_clone.set(results); + last_search_time_clone.set(Some(elapsed)); + }, + Err(e) => { + if e.starts_with("DATABASE_CORRUPTED:") { + search_error_clone.set(Some("Database appears to be corrupted".to_string())); + show_corruption_dialog_clone.set(true); + } else { + search_error_clone.set(Some(e)); + } + last_search_time_clone.set(Some(elapsed)); + } + } + }, + Err(e) => { + search_error_clone.set(Some(format!("Task execution error: {}", e))); + last_search_time_clone.set(Some(elapsed)); + } + } + is_searching_clone.set(false); + }); + } + }; + rsx! { div { - style: "margin-top: 30px;", + class: "section", h2 { "Search Database" } div { - style: "margin-bottom: 10px;", + class: "form-group", label { "Search Type: " } select { + class: "form-control", value: "{search_type}", onchange: move |evt| search_type.set(evt.value()), option { value: "fulltext", "Full Text Search" } @@ -44,92 +163,104 @@ pub fn Search(props: SearchProps) -> Element { if search_type() != "duplicates" { div { - style: "margin-bottom: 10px;", + class: "form-group", label { "Search Term: " } input { + class: "form-control", r#type: "text", value: "{search_term}", - oninput: move |evt| search_term.set(evt.value()) + oninput: move |evt| search_term.set(evt.value()), + onkeydown: { + let perform_search = perform_search.clone(); + move |evt| { + if evt.code() == dioxus::events::Code::Enter { + perform_search(); + } + } + } } } } - button { - style: "padding: 10px 20px; background-color: #2196F3; color: white; border: none; cursor: pointer;", - onclick: move |_| { - let service_clone = service.clone(); - let db_clone = db_path.clone(); - let search_type_val = search_type().clone(); - let search_term_val = search_term().clone(); - - let mut search_results_clone = search_results.clone(); - let mut search_error_clone = search_error.clone(); - - spawn(async move { - search_error_clone.set(None); - - let query = match search_type_val.as_str() { - "fulltext" => { - if search_term_val.trim().is_empty() { - search_error_clone.set(Some("Please enter a search term".to_string())); - return; - } - format!("SELECT name, path, snippet(searchabletext, 2, '', '', '...', 64) as snippet FROM searchabletext WHERE text MATCH '{}'", search_term_val.replace("'", "''")) - }, - "filename" => { - if search_term_val.trim().is_empty() { - search_error_clone.set(Some("Please enter a filename pattern".to_string())); - return; - } - format!("SELECT name, path FROM files WHERE name LIKE '%{}%'", search_term_val.replace("'", "''")) - }, - "duplicates" => "SELECT name, count(*) as cnt, path FROM files GROUP BY hash HAVING cnt > 1 ORDER BY cnt DESC".to_string(), - _ => return - }; - - match service_clone.execute_search(&db_clone, &query) { - Ok(results) => search_results_clone.set(results), - Err(e) => search_error_clone.set(Some(e)) + div { + style: "display: flex; align-items: center; gap: 10px;", + button { + class: "btn btn-info", + disabled: is_searching(), + onclick: { + let perform_search = perform_search.clone(); + move |_| { + perform_search(); } - }); - }, - "Search" + }, + "Search" + } + + if is_searching() { + div { + class: "loading", + title: "Searching..." + } + } else if let Some(elapsed) = last_search_time() { + span { + style: "color: #666; font-size: 0.9em;", + "Search completed in {elapsed:.3}s" + } + } } if let Some(error) = search_error() { div { - style: "color: red; margin-top: 10px;", + class: "error-message", "Error: {error}" } } if !search_results().is_empty() { div { - style: "margin-top: 20px;", - h3 { "Search Results ({search_results()[0].rows.len()} rows)" } + class: "search-results", + h3 { "Search Results ({search_results()[0].rows.len()} results)" } div { - style: "max-height: 400px; overflow: auto; border: 1px solid #ddd;", + class: "results-table", table { - style: "width: 100%; border-collapse: collapse; font-size: 12px;", thead { - style: "background-color: #f5f5f5; position: sticky; top: 0;", tr { for column in search_results()[0].columns.iter() { - th { - style: "padding: 8px; border: 1px solid #ddd; text-align: left;", - "{column}" - } + th { "{column}" } } } } tbody { - for (i, row) in search_results()[0].rows.iter().enumerate() { + for (_i, row) in search_results()[0].rows.iter().enumerate() { tr { - style: if i % 2 == 0 { "background-color: #f9f9f9;" } else { "" }, - for value in row.values.iter() { - td { - style: "padding: 8px; border: 1px solid #ddd; word-break: break-all;", - dangerous_inner_html: "{value}" + for (col_index, value) in row.values.iter().enumerate() { + // Check if this column is a path column + if search_results()[0].columns.get(col_index).map(|s| s.as_str()) == Some("path") { + { + let value_owned = value.clone(); + let service_owned = props.indexing_service.clone(); + rsx! { + td { + class: "path-cell clickable", + onclick: move |_| { + let path = value_owned.clone(); + let service_clone = service_owned.clone(); + + spawn(async move { + if let Err(e) = service_clone.open_file_explorer(&path) { + eprintln!("Failed to open file explorer: {}", e); + } + }); + }, + title: "Click to open in file explorer", + dangerous_inner_html: "{value}" + } + } + } + } else { + td { + dangerous_inner_html: "{value}" + } } } } @@ -139,6 +270,60 @@ pub fn Search(props: SearchProps) -> Element { } } } + + // Database corruption recovery dialog + if show_corruption_dialog() { + div { + class: "modal-backdrop", + div { + class: "modal-dialog", + h3 { + style: "margin-top: 0; color: #d32f2f;", + "⚠️ Database Corruption Detected" + } + p { + style: "margin: 15px 0;", + "The database appears to be corrupted or malformed. This can happen due to unexpected shutdowns or disk issues." + } + p { + style: "margin: 15px 0; font-weight: bold;", + "Would you like to delete the corrupted database and create a new one? This will require re-indexing your files." + } + div { + style: "display: flex; gap: 10px; margin-top: 20px;", + button { + style: "padding: 10px 20px; background-color: #d32f2f; color: white; border: none; border-radius: 5px; cursor: pointer;", + onclick: move |_| { + let service = props.indexing_service.clone(); + let db = props.db_path.clone(); + + show_corruption_dialog.set(false); + search_error.set(Some("Deleting corrupted database...".to_string())); + + spawn(async move { + match service.delete_index_for_rebuild(&db) { + Ok(()) => { + search_error.set(Some("Database deleted. You can now start indexing again.".to_string())); + } + Err(e) => { + search_error.set(Some(format!("Error deleting database: {}", e))); + } + } + }); + }, + "Yes, Delete & Rebuild" + } + button { + style: "padding: 10px 20px; background-color: #666; color: white; border: none; border-radius: 5px; cursor: pointer;", + onclick: move |_| { + show_corruption_dialog.set(false); + }, + "Cancel" + } + } + } + } + } } } }