updated prompt design

This commit is contained in:
nvrl committed 2026-07-10 16:03:31 +02:00
1 parent e77ab0ffa9
commit 924e07e85f
4 files changed
+462 -93

No files matched your search

Generated
+8 -8
View File
@@ -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",
+3 -4
View File
@@ -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 <contact@narl.io>"]
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" }
+13 -3
View File
@@ -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::<Vec<_>>()
.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]
+438 -78
View File
@@ -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::<SearchHit>::new()));
let raw_hits = Rc::new(RefCell::new(Vec::<SearchHit>::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,
&current_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::<glib::SourceId>));
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,
&current_filter_clone,
select_filter_clone,
&socket_clone,
&status_label_clone,
&spinner_clone,
);
} else {
run_prompt_search(
&list_box_clone,
&hits_clone,
&raw_hits_clone,
&current_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: &gtk4::ListBox,
hits: &Rc<RefCell<Vec<SearchHit>>>,
raw_hits: &Rc<RefCell<Vec<SearchHit>>>,
current_filter: &Rc<Cell<FileFilter>>,
select_filter: Rc<dyn Fn(FileFilter)>,
socket: &Path,
status_label: &gtk4::Label,
spinner: &gtk4::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: &gtk4::ListBox,
hits: &Rc<RefCell<Vec<SearchHit>>>,
raw_hits: &Rc<RefCell<Vec<SearchHit>>>,
current_filter: &Rc<Cell<FileFilter>>,
select_filter: Rc<dyn Fn(FileFilter)>,
socket: &Path,
query: &str,
status_label: &gtk4::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: &gtk4::ListBox,
hits: &Rc<RefCell<Vec<SearchHit>>>,
raw_hits: &[SearchHit],
filter: FileFilter,
) {
let filtered: Vec<SearchHit> = 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: &gtk4::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")
}