mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
updated prompt design
This commit is contained in:
1 parent
e77ab0ffa9
commit
924e07e85f
4 files changed
+444
-75
No files matched your search
Generated
+8
-8
@@ -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
@@ -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" }
|
||||
|
||||
@@ -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]
|
||||
|
||||
+420
-60
@@ -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,24 +366,105 @@ 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 || {
|
||||
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, .. }) => {
|
||||
@@ -282,8 +472,9 @@ fn build_window(app: &adw::Application) {
|
||||
stack_clone.set_visible_child_name("search");
|
||||
entry_clone.set_sensitive(true);
|
||||
load_initial_pins(
|
||||
&list_box_clone,
|
||||
&hits_clone,
|
||||
&raw_hits_clone,
|
||||
¤t_filter_clone,
|
||||
select_filter_conn.clone(),
|
||||
&socket_clone,
|
||||
&status_label_clone,
|
||||
&spinner_clone,
|
||||
@@ -297,6 +488,7 @@ fn build_window(app: &adw::Application) {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let check_conn = check_connection.clone();
|
||||
@@ -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,
|
||||
¤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<RefCell<Vec<SearchHit>>>,
|
||||
raw_hits: &Rc<RefCell<Vec<SearchHit>>>,
|
||||
current_filter: &Rc<Cell<FileFilter>>,
|
||||
select_filter: Rc<dyn Fn(FileFilter)>,
|
||||
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<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: >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<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: >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")
|
||||
}
|
||||
Reference in new issue
Block a user