diff --git a/Cargo.lock b/Cargo.lock index 059cdd7..b58ab84 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2825,7 +2825,7 @@ dependencies = [ [[package]] name = "pdfs-cli" -version = "0.2.3" +version = "0.2.4" dependencies = [ "anyhow", "clap 4.6.1", @@ -2840,7 +2840,7 @@ dependencies = [ [[package]] name = "pdfs-core" -version = "0.2.3" +version = "0.2.4" dependencies = [ "directories", "keyring", @@ -2857,7 +2857,7 @@ dependencies = [ [[package]] name = "pdfs-fuse" -version = "0.2.3" +version = "0.2.4" dependencies = [ "fuser", "libc", @@ -2870,7 +2870,7 @@ dependencies = [ [[package]] name = "pdfs-gui" -version = "0.2.3" +version = "0.2.4" dependencies = [ "anyhow", "async-channel", @@ -3085,9 +3085,9 @@ dependencies = [ [[package]] name = "proton-drive-rs" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76e4399b4d9b076c5886c7db87ae7f70322dd7127e6c5a4e7eff8719434d2434" +checksum = "2233c256d048c94bc6e59d5332b908e69852ee16690840d02d44e8b3c708aece" dependencies = [ "async-trait", "base64", @@ -3110,9 +3110,9 @@ dependencies = [ [[package]] name = "proton-sdk" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "099f6f20709452f2cdb0cee4db0a82e61a29bec261295812908bf956ffcdaa00" +checksum = "a415594c96d3ca2d9c0db9fbd63b645447ed3565b0d683b5a403b42f54a64818" dependencies = [ "aes-gcm", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 4eb31f3..50bfab8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,16 +3,15 @@ resolver = "2" members = ["crates/pdfs-core", "crates/pdfs-fuse", "crates/pdfs-cli", "crates/pdfs-gui"] [workspace.package] -version = "0.2.3" +version = "0.2.4" edition = "2024" license = "MIT" authors = ["Nils Pukropp "] rust-version = "1.96" [workspace.dependencies] -# Local Proton SDK crates (path deps until published; switch to versioned later). -proton-sdk = "0.1.3" -proton-drive-rs = "0.1.3" +proton-sdk = "0.1.4" +proton-drive-rs = "0.1.4" # Internal crates. pdfs-core = { path = "crates/pdfs-core" } diff --git a/crates/pdfs-core/src/db.rs b/crates/pdfs-core/src/db.rs index 9c717a4..b059cf6 100644 --- a/crates/pdfs-core/src/db.rs +++ b/crates/pdfs-core/src/db.rs @@ -213,9 +213,13 @@ impl Db { )?; collect_pairs(stmt.query_map(params![pat, limit as i64], pair)?)? } else { - // Quote the user input as a single FTS phrase so its characters are - // matched literally, never parsed as FTS query syntax. - let phrase = format!("\"{}\"", query.replace('"', "\"\"")); + // Escape double quotes and quote each term, then combine with AND so + // all terms must match but can appear in any order or position. + let phrase = query + .split_whitespace() + .map(|word| format!("\"{}\"", word.replace('"', "\"\""))) + .collect::>() + .join(" AND "); let mut stmt = conn.prepare( "SELECT n.node_json, n.uid FROM nodes_fts f JOIN nodes n ON n.uid = f.uid @@ -805,6 +809,7 @@ mod tests { db.upsert_node(&file("f1", "docs", "report.pdf", 1)) .unwrap(); db.upsert_node(&file("f2", "root", "notes.txt", 2)).unwrap(); + db.upsert_node(&file("f3", "root", "Rampage Open Air 2026 - order 166765244.pdf", 3)).unwrap(); // Substring match (trigram), not just prefix. let hits = db.search("port", 10).unwrap(); @@ -816,6 +821,11 @@ mod tests { // Top-level file → bare name. let hits = db.search("notes", 10).unwrap(); assert_eq!(hits[0].path, "notes.txt"); + + // Multi-term FTS5 query matching (out of order, separated terms) + let hits = db.search("rampage 2026", 10).unwrap(); + assert_eq!(hits.len(), 1); + assert_eq!(hits[0].node.name, "Rampage Open Air 2026 - order 166765244.pdf"); } #[test] diff --git a/crates/pdfs-gui/src/prompt.rs b/crates/pdfs-gui/src/prompt.rs index bcaaf94..6297ec3 100644 --- a/crates/pdfs-gui/src/prompt.rs +++ b/crates/pdfs-gui/src/prompt.rs @@ -19,6 +19,15 @@ const APP_ID: &str = "io.narl.proton-drive-linux-prompt"; const PROTON_PURPLE: &str = "#6d4aff"; const SEARCH_DEBOUNCE: Duration = Duration::from_millis(150); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FileFilter { + All, + Folders, + PDFs, + Documents, + Images, +} + /// Run one blocking control-socket round-trip on a worker thread, returning a /// channel that yields the [`Response`] once. fn spawn_request( @@ -62,25 +71,55 @@ fn load_prompt_theme() { }}\n\ \n\ .launcher-container {{\n\ - background-color: @window_bg_color;\n\ - border: 2px solid {PROTON_PURPLE};\n\ - border-radius: 12px;\n\ - padding: 12px;\n\ - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);\n\ + background: linear-gradient(135deg, #1e1b2e 0%, #14121e 100%);\n\ + border: 1px solid rgba(109, 74, 255, 0.3);\n\ + border-radius: 16px;\n\ + padding: 16px;\n\ + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.6), 0 0 30px rgba(109, 74, 255, 0.15);\n\ }}\n\ \n\ .launcher-header-box {{\n\ - padding: 6px 12px;\n\ - background-color: alpha(@view_bg_color, 0.4);\n\ - border-radius: 8px;\n\ - border: 1px solid alpha({PROTON_PURPLE}, 0.2);\n\ + padding: 10px 16px;\n\ + background-color: rgba(255, 255, 255, 0.04);\n\ + border-radius: 12px;\n\ + border: 1px solid rgba(109, 74, 255, 0.2);\n\ }}\n\ \n\ .launcher-entry {{\n\ - font-size: 1.15rem;\n\ + font-size: 1.25rem;\n\ + font-weight: 500;\n\ background: transparent;\n\ border: none;\n\ box-shadow: none;\n\ + color: white;\n\ + }}\n\ + \n\ + .filter-bar {{\n\ + margin: 8px 0px 4px 0px;\n\ + }}\n\ + \n\ + .filter-chip {{\n\ + background-color: rgba(255, 255, 255, 0.03);\n\ + border: 1px solid rgba(255, 255, 255, 0.08);\n\ + border-radius: 20px;\n\ + padding: 6px 14px;\n\ + color: rgba(255, 255, 255, 0.7);\n\ + font-size: 0.85rem;\n\ + font-weight: 500;\n\ + transition: all 0.15s ease-in-out;\n\ + }}\n\ + \n\ + .filter-chip:hover {{\n\ + background-color: rgba(109, 74, 255, 0.15);\n\ + border-color: rgba(109, 74, 255, 0.4);\n\ + color: white;\n\ + }}\n\ + \n\ + .filter-chip.active {{\n\ + background-color: {PROTON_PURPLE};\n\ + border-color: {PROTON_PURPLE};\n\ + color: white;\n\ + box-shadow: 0 2px 8px rgba(109, 74, 255, 0.4);\n\ }}\n\ \n\ .launcher-listbox {{\n\ @@ -88,37 +127,82 @@ fn load_prompt_theme() { }}\n\ \n\ .launcher-row {{\n\ - padding: 10px 14px;\n\ - border-radius: 6px;\n\ - margin: 2px 0px;\n\ + padding: 12px 16px;\n\ + border-radius: 10px;\n\ + margin: 3px 0px;\n\ transition: background-color 0.15s ease, color 0.15s ease;\n\ }}\n\ \n\ .launcher-row:hover {{\n\ - background-color: alpha({PROTON_PURPLE}, 0.1);\n\ + background-color: rgba(109, 74, 255, 0.08);\n\ }}\n\ \n\ .launcher-row:selected {{\n\ background-color: {PROTON_PURPLE};\n\ color: white;\n\ + box-shadow: 0 4px 12px rgba(109, 74, 255, 0.3);\n\ }}\n\ \n\ .launcher-row:selected .dim-label {{\n\ - color: alpha(white, 0.7);\n\ + color: rgba(255, 255, 255, 0.7);\n\ + opacity: 0.9;\n\ + }}\n\ + \n\ + .launcher-row:selected .icon-folder {{\n\ + color: white;\n\ + }}\n\ + \n\ + .launcher-row:selected .icon-pdf {{\n\ + color: white;\n\ + }}\n\ + \n\ + .launcher-row:selected .icon-doc {{\n\ + color: white;\n\ + }}\n\ + \n\ + .launcher-row:selected .icon-image {{\n\ + color: white;\n\ + }}\n\ + \n\ + .launcher-row:selected .icon-pin {{\n\ + color: white;\n\ }}\n\ \n\ .launcher-footer {{\n\ - padding: 6px 12px 0px 12px;\n\ - font-size: 0.85rem;\n\ - border-top: 1px solid alpha({PROTON_PURPLE}, 0.1);\n\ + padding: 10px 12px 0px 12px;\n\ + font-size: 0.82rem;\n\ + border-top: 1px solid rgba(255, 255, 255, 0.08);\n\ }}\n\ \n\ .dim-label {{\n\ - color: @view_fg_color;\n\ - opacity: 0.6;\n\ + color: rgba(255, 255, 255, 0.45);\n\ font-size: 0.85rem;\n\ }}\n\ \n\ + .icon-folder {{\n\ + color: #35b3ff;\n\ + }}\n\ + \n\ + .icon-pdf {{\n\ + color: #ff4a4a;\n\ + }}\n\ + \n\ + .icon-doc {{\n\ + color: #2ecc71;\n\ + }}\n\ + \n\ + .icon-image {{\n\ + color: #e67e22;\n\ + }}\n\ + \n\ + .icon-generic {{\n\ + color: #a0a0b0;\n\ + }}\n\ + \n\ + .icon-pin {{\n\ + color: #f1c40f;\n\ + }}\n\ + \n\ .error-box {{\n\ padding: 24px;\n\ }}\n\ @@ -127,6 +211,26 @@ fn load_prompt_theme() { font-size: 1.25rem;\n\ font-weight: bold;\n\ margin-bottom: 8px;\n\ + color: white;\n\ + }}\n\ + \n\ + scrolledwindow viewport {{\n\ + background: transparent;\n\ + }}\n\ + \n\ + scrollbar {{\n\ + background: transparent;\n\ + }}\n\ + \n\ + scrollbar slider {{\n\ + background: rgba(255, 255, 255, 0.1);\n\ + border-radius: 4px;\n\ + min-width: 6px;\n\ + min-height: 6px;\n\ + }}\n\ + \n\ + scrollbar slider:hover {{\n\ + background: rgba(109, 74, 255, 0.4);\n\ }}\n" ); @@ -190,6 +294,11 @@ fn build_window(app: &adw::Application) { container.append(&header_box); + // Horizontal box for Google Drive style filter chips + let filter_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 6); + filter_box.add_css_class("filter-bar"); + container.append(&filter_box); + let separator = gtk4::Separator::new(gtk4::Orientation::Horizontal); container.append(&separator); @@ -244,7 +353,7 @@ fn build_window(app: &adw::Application) { stack.add_named(&error_box, Some("error")); - // Footer box: status label + // Footer box: status label and shortcut helper let footer_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); footer_box.add_css_class("launcher-footer"); @@ -257,44 +366,127 @@ fn build_window(app: &adw::Application) { status_label.add_css_class("dim-label"); footer_box.append(&status_label); + let shortcut_guide = gtk4::Label::builder() + .label("Tab: cycle filters • Enter: open • Esc: close") + .halign(gtk4::Align::End) + .build(); + shortcut_guide.add_css_class("dim-label"); + footer_box.append(&shortcut_guide); + container.append(&footer_box); // Shared state variables let hits = Rc::new(RefCell::new(Vec::::new())); + let raw_hits = Rc::new(RefCell::new(Vec::::new())); + let current_filter = Rc::new(Cell::new(FileFilter::All)); let mountpoint = Rc::new(RefCell::new(default_mountpoint.display().to_string())); let is_opening = Rc::new(Cell::new(false)); + // Define filter button mapping and setup select closure + let filter_buttons = Rc::new(RefCell::new(Vec::<(FileFilter, gtk4::Button)>::new())); + + let select_filter_rc = { + let current_filter = current_filter.clone(); + let filter_buttons = filter_buttons.clone(); + let list_box = list_box.clone(); + let hits = hits.clone(); + let raw_hits = raw_hits.clone(); + let status_label = status_label.clone(); + + Rc::new(move |filter: FileFilter| { + current_filter.set(filter); + + // Update active CSS class on buttons + for (f, btn) in filter_buttons.borrow().iter() { + if *f == filter { + btn.add_css_class("active"); + } else { + btn.remove_css_class("active"); + } + } + + // Re-apply filter + apply_filter(&list_box, &hits, &raw_hits.borrow(), filter); + + // Update status text based on filter matches + let filtered_count = hits.borrow().len(); + let total_count = raw_hits.borrow().len(); + if total_count == 0 { + status_label.set_text("No files loaded."); + } else if filter == FileFilter::All { + status_label.set_text(&format!("Showing all {total_count} files.")); + } else { + status_label.set_text(&format!( + "Found {filtered_count} matches (filtered from {total_count} total)." + )); + } + }) + }; + + // Populate filter bar + let filter_names = [ + (FileFilter::All, "All", "system-search-symbolic"), + (FileFilter::Folders, "Folders", "folder-symbolic"), + (FileFilter::PDFs, "PDFs", "document-symbolic"), + (FileFilter::Documents, "Documents", "document-symbolic"), + (FileFilter::Images, "Images", "image-symbolic"), + ]; + + for (filter_type, label, icon) in filter_names { + let btn = gtk4::Button::builder() + .label(label) + .icon_name(icon) + .build(); + btn.add_css_class("filter-chip"); + if filter_type == FileFilter::All { + btn.add_css_class("active"); + } + + let select_filter_clone = select_filter_rc.clone(); + btn.connect_clicked(move |_| { + select_filter_clone(filter_type); + }); + + filter_box.append(&btn); + filter_buttons.borrow_mut().push((filter_type, btn)); + } + // Handle retry on connection failure let stack_clone = stack.clone(); let socket_clone = socket.clone(); let mountpoint_clone = mountpoint.clone(); let status_label_clone = status_label.clone(); let spinner_clone = spinner.clone(); - let list_box_clone = list_box.clone(); - let hits_clone = hits.clone(); + let raw_hits_clone = raw_hits.clone(); + let current_filter_clone = current_filter.clone(); + let select_filter_conn = select_filter_rc.clone(); let entry_clone = entry.clone(); - let check_connection = move || { - status_label_clone.set_text("Connecting to daemon..."); - match send(&socket_clone, &Request::Status) { - Ok(Response::Status { mountpoint: mp, .. }) => { - *mountpoint_clone.borrow_mut() = mp; - stack_clone.set_visible_child_name("search"); - entry_clone.set_sensitive(true); - load_initial_pins( - &list_box_clone, - &hits_clone, - &socket_clone, - &status_label_clone, - &spinner_clone, - ); - true - } - _ => { - stack_clone.set_visible_child_name("error"); - entry_clone.set_sensitive(false); - status_label_clone.set_text("Daemon offline."); - false + let check_connection = { + let select_filter_conn = select_filter_conn.clone(); + move || { + status_label_clone.set_text("Connecting to daemon..."); + match send(&socket_clone, &Request::Status) { + Ok(Response::Status { mountpoint: mp, .. }) => { + *mountpoint_clone.borrow_mut() = mp; + stack_clone.set_visible_child_name("search"); + entry_clone.set_sensitive(true); + load_initial_pins( + &raw_hits_clone, + ¤t_filter_clone, + select_filter_conn.clone(), + &socket_clone, + &status_label_clone, + &spinner_clone, + ); + true + } + _ => { + stack_clone.set_visible_child_name("error"); + entry_clone.set_sensitive(false); + status_label_clone.set_text("Daemon offline."); + false + } } } }; @@ -323,6 +515,8 @@ fn build_window(app: &adw::Application) { let entry_keys = entry.clone(); let hits_keys = hits.clone(); let is_opening_keys = is_opening.clone(); + let current_filter_keys = current_filter.clone(); + let select_filter_keys = select_filter_rc.clone(); key_controller.connect_key_pressed(move |_, key, _keycode, _state| { if is_opening_keys.get() { @@ -334,6 +528,28 @@ fn build_window(app: &adw::Application) { window_keys.close(); glib::Propagation::Stop } + gtk4::gdk::Key::Tab => { + let is_shift = _state.contains(gtk4::gdk::ModifierType::SHIFT_MASK); + let next_filter = if is_shift { + match current_filter_keys.get() { + FileFilter::All => FileFilter::Images, + FileFilter::Folders => FileFilter::All, + FileFilter::PDFs => FileFilter::Folders, + FileFilter::Documents => FileFilter::PDFs, + FileFilter::Images => FileFilter::Documents, + } + } else { + match current_filter_keys.get() { + FileFilter::All => FileFilter::Folders, + FileFilter::Folders => FileFilter::PDFs, + FileFilter::PDFs => FileFilter::Documents, + FileFilter::Documents => FileFilter::Images, + FileFilter::Images => FileFilter::All, + } + }; + select_filter_keys(next_filter); + glib::Propagation::Stop + } gtk4::gdk::Key::Down => { let count = hits_keys.borrow().len(); if count > 0 { @@ -437,8 +653,9 @@ fn build_window(app: &adw::Application) { // Debounced search text entry listener let search_source = Rc::new(RefCell::new(None::)); let search_source_changed = search_source.clone(); - let list_box_search = list_box.clone(); - let hits_search = hits.clone(); + let raw_hits_search = raw_hits.clone(); + let current_filter_search = current_filter.clone(); + let select_filter_search = select_filter_rc.clone(); let socket_search = socket.clone(); let status_label_search = status_label.clone(); let spinner_search = spinner.clone(); @@ -449,8 +666,9 @@ fn build_window(app: &adw::Application) { } let ent_clone = ent.clone(); - let list_box_clone = list_box_search.clone(); - let hits_clone = hits_search.clone(); + let raw_hits_clone = raw_hits_search.clone(); + let current_filter_clone = current_filter_search.clone(); + let select_filter_clone = select_filter_search.clone(); let socket_clone = socket_search.clone(); let status_label_clone = status_label_search.clone(); let spinner_clone = spinner_search.clone(); @@ -461,16 +679,18 @@ fn build_window(app: &adw::Application) { let query = ent_clone.text().trim().to_string(); if query.is_empty() { load_initial_pins( - &list_box_clone, - &hits_clone, + &raw_hits_clone, + ¤t_filter_clone, + select_filter_clone, &socket_clone, &status_label_clone, &spinner_clone, ); } else { run_prompt_search( - &list_box_clone, - &hits_clone, + &raw_hits_clone, + ¤t_filter_clone, + select_filter_clone, &socket_clone, &query, &status_label_clone, @@ -489,8 +709,9 @@ fn build_window(app: &adw::Application) { /// Retrieve pinned files from the daemon and render them in the results list. fn load_initial_pins( - list_box: >k4::ListBox, - hits: &Rc>>, + raw_hits: &Rc>>, + current_filter: &Rc>, + select_filter: Rc, socket: &Path, status_label: >k4::Label, spinner: >k4::Spinner, @@ -500,8 +721,9 @@ fn load_initial_pins( spinner.start(); let rx = spawn_request(socket.to_path_buf(), Request::ListPins); - let list_box = list_box.clone(); - let hits = hits.clone(); + let raw_hits = raw_hits.clone(); + let current_filter = current_filter.clone(); + let select_filter = select_filter.clone(); let status_label = status_label.clone(); let spinner = spinner.clone(); @@ -533,12 +755,8 @@ fn load_initial_pins( }) .collect(); - repaint_prompt_results(&list_box, &hits, &mapped_hits); - if mapped_hits.is_empty() { - status_label.set_text("No pinned files. Type to search."); - } else { - status_label.set_text("Showing pinned files. Type to search."); - } + *raw_hits.borrow_mut() = mapped_hits; + select_filter(current_filter.get()); } _ => { status_label.set_text("Failed to load pinned files. Type to search."); @@ -549,8 +767,9 @@ fn load_initial_pins( /// Run search on the daemon and update the results list. fn run_prompt_search( - list_box: >k4::ListBox, - hits: &Rc>>, + raw_hits: &Rc>>, + current_filter: &Rc>, + select_filter: Rc, socket: &Path, query: &str, status_label: >k4::Label, @@ -567,8 +786,9 @@ fn run_prompt_search( limit: 30, }, ); - let list_box = list_box.clone(); - let hits = hits.clone(); + let raw_hits = raw_hits.clone(); + let current_filter = current_filter.clone(); + let select_filter = select_filter.clone(); let status_label = status_label.clone(); let spinner = spinner.clone(); @@ -579,12 +799,8 @@ fn run_prompt_search( match res { Ok(Ok(Response::SearchResults { hits: search_hits })) => { - repaint_prompt_results(&list_box, &hits, &search_hits); - if search_hits.is_empty() { - status_label.set_text("No matches found."); - } else { - status_label.set_text(&format!("Found {} matches.", search_hits.len())); - } + *raw_hits.borrow_mut() = search_hits; + select_filter(current_filter.get()); } Ok(Ok(Response::Error { message })) => { status_label.set_text(&format!("Search error: {message}")); @@ -596,6 +812,50 @@ fn run_prompt_search( }); } +/// Apply local filter criteria to raw hits and repaint list view. +fn apply_filter( + list_box: >k4::ListBox, + hits: &Rc>>, + raw_hits: &[SearchHit], + filter: FileFilter, +) { + let filtered: Vec = raw_hits + .iter() + .filter(|hit| match filter { + FileFilter::All => true, + FileFilter::Folders => hit.is_dir, + FileFilter::PDFs => !hit.is_dir && hit.name.to_lowercase().ends_with(".pdf"), + FileFilter::Documents => { + !hit.is_dir + && (hit.name.to_lowercase().ends_with(".docx") + || hit.name.to_lowercase().ends_with(".doc") + || hit.name.to_lowercase().ends_with(".txt") + || hit.name.to_lowercase().ends_with(".md") + || hit.name.to_lowercase().ends_with(".odt") + || hit.name.to_lowercase().ends_with(".pdf") + || hit.name.to_lowercase().ends_with(".xlsx") + || hit.name.to_lowercase().ends_with(".xls") + || hit.name.to_lowercase().ends_with(".csv") + || hit.name.to_lowercase().ends_with(".pptx") + || hit.name.to_lowercase().ends_with(".ppt")) + } + FileFilter::Images => { + !hit.is_dir + && (hit.name.to_lowercase().ends_with(".png") + || hit.name.to_lowercase().ends_with(".jpg") + || hit.name.to_lowercase().ends_with(".jpeg") + || hit.name.to_lowercase().ends_with(".webp") + || hit.name.to_lowercase().ends_with(".gif") + || hit.name.to_lowercase().ends_with(".bmp") + || hit.name.to_lowercase().ends_with(".svg")) + } + }) + .cloned() + .collect(); + + repaint_prompt_results(list_box, hits, &filtered); +} + /// Render the provided search hits into the ListBox. fn repaint_prompt_results( list_box: >k4::ListBox, @@ -615,12 +875,23 @@ fn repaint_prompt_results( let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 12); - let icon_name = if hit.is_dir { - "folder-symbolic" + // Select icon and add appropriate class for modern custom styling colors + let (icon_name, icon_class) = if hit.is_dir { + ("folder-symbolic", "icon-folder") } else { - "document-symbolic" + let ext = hit.name.split('.').last().unwrap_or("").to_lowercase(); + if ext == "pdf" { + ("document-symbolic", "icon-pdf") + } else if ["docx", "doc", "txt", "md", "odt", "xlsx", "xls", "csv", "pptx", "ppt"].contains(&ext.as_str()) { + ("document-symbolic", "icon-doc") + } else if ["png", "jpg", "jpeg", "webp", "gif", "bmp", "svg"].contains(&ext.as_str()) { + ("image-symbolic", "icon-image") + } else { + ("document-symbolic", "icon-generic") + } }; let icon = gtk4::Image::from_icon_name(icon_name); + icon.add_css_class(icon_class); row_box.append(&icon); let text_box = gtk4::Box::new(gtk4::Orientation::Vertical, 2); @@ -643,16 +914,47 @@ fn repaint_prompt_results( text_box.append(&path_label); row_box.append(&text_box); - row.set_child(Some(&row_box)); + // Right details box + let right_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8); + right_box.set_valign(gtk4::Align::Center); + + if hit.pinned { + let pin_icon = gtk4::Image::from_icon_name("emblem-favorite-symbolic"); + pin_icon.add_css_class("icon-pin"); + right_box.append(&pin_icon); + } + + if !hit.is_dir && hit.size > 0 { + let size_str = format_size(hit.size); + let size_label = gtk4::Label::builder() + .label(&size_str) + .build(); + size_label.add_css_class("dim-label"); + right_box.append(&size_label); + } + + if hit.modified > 0 { + let time_str = format_relative_time(hit.modified); + let time_label = gtk4::Label::builder() + .label(&time_str) + .build(); + time_label.add_css_class("dim-label"); + right_box.append(&time_label); + } + + row_box.append(&right_box); + + row.set_child(Some(&row_box)); list_box.append(&row); } // Automatically select the first item - if !new_hits.is_empty() - && let Some(row) = list_box.row_at_index(0) { + if !new_hits.is_empty() { + if let Some(row) = list_box.row_at_index(0) { list_box.select_row(Some(&row)); } + } } /// Open the selected search hit. Directories open FUSE directly, files trigger cache download. @@ -714,3 +1016,61 @@ fn open_hit( }); } } + +/// Helper function to format sizes in human-readable terms. +fn format_size(bytes: u64) -> String { + if bytes == 0 { + return String::new(); + } + const KB: u64 = 1024; + const MB: u64 = 1024 * 1024; + const GB: u64 = 1024 * 1024 * 1024; + if bytes >= GB { + format!("{:.1} GB", bytes as f64 / GB as f64) + } else if bytes >= MB { + format!("{:.1} MB", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{:.1} KB", bytes as f64 / KB as f64) + } else { + format!("{bytes} B") + } +} + +/// Helper function to format relative timestamps. +fn format_relative_time(epoch_secs: i64) -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + + let diff = now - epoch_secs; + if diff <= 0 { + return "Just now".to_string(); + } + if diff < 60 { + return "Just now".to_string(); + } + let mins = diff / 60; + if mins < 60 { + return format!("{mins}m ago"); + } + let hours = mins / 60; + if hours < 24 { + return format!("{hours}h ago"); + } + let days = hours / 24; + if days < 7 { + return format!("{days}d ago"); + } + + if days < 30 { + return format!("{days}d ago"); + } + let months = days / 30; + if months < 12 { + return format!("{months}mo ago"); + } + let years = months / 12; + format!("{years}y ago") +}