mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
implemented proton drive prompt
This commit is contained in:
1 parent
5766bc4ed1
commit
d131d3dca8
3 files changed
+723
-1
No files matched your search
@@ -14,6 +14,10 @@ path = "src/main.rs"
|
||||
name = "pdfs-app"
|
||||
path = "src/app.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "pdfs-prompt"
|
||||
path = "src/prompt.rs"
|
||||
|
||||
[dependencies]
|
||||
pdfs-core.workspace = true
|
||||
tracing.workspace = true
|
||||
|
||||
@@ -0,0 +1,716 @@
|
||||
//! `pdfs-prompt` — a fast, Google Drive-style launcher prompt for searching
|
||||
//! and opening files or folders in Proton Drive.
|
||||
//!
|
||||
//! Designed to be bound to a system shortcut (e.g. in Hyprland) for quick HUD search.
|
||||
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::rc::Rc;
|
||||
use std::time::Duration;
|
||||
|
||||
use adw::prelude::*;
|
||||
use gtk4::glib;
|
||||
|
||||
use pdfs_core::config::AppDirs;
|
||||
use pdfs_core::control::{Request, Response, SearchHit, send};
|
||||
|
||||
const APP_ID: &str = "io.narl.proton-drive-linux-prompt";
|
||||
const PROTON_PURPLE: &str = "#6d4aff";
|
||||
const SEARCH_DEBOUNCE: Duration = Duration::from_millis(150);
|
||||
|
||||
/// Run one blocking control-socket round-trip on a worker thread, returning a
|
||||
/// channel that yields the [`Response`] once.
|
||||
fn spawn_request(
|
||||
socket: PathBuf,
|
||||
req: Request,
|
||||
) -> async_channel::Receiver<Result<Response, String>> {
|
||||
let (tx, rx) = async_channel::bounded(1);
|
||||
std::thread::spawn(move || {
|
||||
let result = send(&socket, &req).map_err(|e| e.to_string());
|
||||
let _ = tx.send_blocking(result);
|
||||
});
|
||||
rx
|
||||
}
|
||||
|
||||
fn main() -> glib::ExitCode {
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| "info".into()),
|
||||
)
|
||||
.init();
|
||||
|
||||
let app = adw::Application::builder().application_id(APP_ID).build();
|
||||
|
||||
app.connect_startup(|_| {
|
||||
load_prompt_theme();
|
||||
});
|
||||
|
||||
app.connect_activate(build_window);
|
||||
|
||||
app.run()
|
||||
}
|
||||
|
||||
/// Override accent colors and style the launcher for a premium HUD look.
|
||||
fn load_prompt_theme() {
|
||||
let css = format!(
|
||||
"@define-color accent_bg_color {PROTON_PURPLE};\n\
|
||||
@define-color accent_color {PROTON_PURPLE};\n\
|
||||
\n\
|
||||
window.launcher-window {{\n\
|
||||
background-color: transparent;\n\
|
||||
}}\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\
|
||||
}}\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\
|
||||
}}\n\
|
||||
\n\
|
||||
.launcher-entry {{\n\
|
||||
font-size: 1.15rem;\n\
|
||||
background: transparent;\n\
|
||||
border: none;\n\
|
||||
box-shadow: none;\n\
|
||||
}}\n\
|
||||
\n\
|
||||
.launcher-listbox {{\n\
|
||||
background: transparent;\n\
|
||||
}}\n\
|
||||
\n\
|
||||
.launcher-row {{\n\
|
||||
padding: 10px 14px;\n\
|
||||
border-radius: 6px;\n\
|
||||
margin: 2px 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\
|
||||
}}\n\
|
||||
\n\
|
||||
.launcher-row:selected {{\n\
|
||||
background-color: {PROTON_PURPLE};\n\
|
||||
color: white;\n\
|
||||
}}\n\
|
||||
\n\
|
||||
.launcher-row:selected .dim-label {{\n\
|
||||
color: alpha(white, 0.7);\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\
|
||||
}}\n\
|
||||
\n\
|
||||
.dim-label {{\n\
|
||||
color: @view_fg_color;\n\
|
||||
opacity: 0.6;\n\
|
||||
font-size: 0.85rem;\n\
|
||||
}}\n\
|
||||
\n\
|
||||
.error-box {{\n\
|
||||
padding: 24px;\n\
|
||||
}}\n\
|
||||
\n\
|
||||
.error-title {{\n\
|
||||
font-size: 1.25rem;\n\
|
||||
font-weight: bold;\n\
|
||||
margin-bottom: 8px;\n\
|
||||
}}\n"
|
||||
);
|
||||
|
||||
let provider = gtk4::CssProvider::new();
|
||||
provider.load_from_string(&css);
|
||||
if let Some(display) = gtk4::gdk::Display::default() {
|
||||
gtk4::style_context_add_provider_for_display(
|
||||
&display,
|
||||
&provider,
|
||||
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn build_window(app: &adw::Application) {
|
||||
let dirs = match AppDirs::new() {
|
||||
Ok(d) => d,
|
||||
Err(e) => {
|
||||
tracing::error!("cannot resolve app dirs: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let socket = dirs.control_socket();
|
||||
let default_mountpoint = dirs.default_mountpoint();
|
||||
|
||||
let window = adw::ApplicationWindow::builder()
|
||||
.application(app)
|
||||
.title("Proton Drive Launcher")
|
||||
.default_width(650)
|
||||
.default_height(450)
|
||||
.decorated(false)
|
||||
.build();
|
||||
|
||||
window.add_css_class("launcher-window");
|
||||
|
||||
// Prefer dark mode for high-tech HUD look
|
||||
let style_manager = adw::StyleManager::default();
|
||||
style_manager.set_color_scheme(adw::ColorScheme::PreferDark);
|
||||
|
||||
let container = gtk4::Box::new(gtk4::Orientation::Vertical, 8);
|
||||
container.add_css_class("launcher-container");
|
||||
window.set_content(Some(&container));
|
||||
|
||||
// Header box: search icon, entry field, and loading spinner
|
||||
let header_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
header_box.add_css_class("launcher-header-box");
|
||||
|
||||
let search_icon = gtk4::Image::from_icon_name("system-search-symbolic");
|
||||
header_box.append(&search_icon);
|
||||
|
||||
let entry = gtk4::Entry::builder()
|
||||
.placeholder_text("Search Proton Drive...")
|
||||
.hexpand(true)
|
||||
.build();
|
||||
entry.add_css_class("launcher-entry");
|
||||
header_box.append(&entry);
|
||||
|
||||
let spinner = gtk4::Spinner::new();
|
||||
spinner.set_visible(false);
|
||||
header_box.append(&spinner);
|
||||
|
||||
container.append(&header_box);
|
||||
|
||||
let separator = gtk4::Separator::new(gtk4::Orientation::Horizontal);
|
||||
container.append(&separator);
|
||||
|
||||
// View stack to transition between results list and warning page
|
||||
let stack = adw::ViewStack::new();
|
||||
container.append(&stack);
|
||||
|
||||
// Page 1: Scrolled window and listbox for results
|
||||
let list_box = gtk4::ListBox::new();
|
||||
list_box.add_css_class("launcher-listbox");
|
||||
|
||||
let scrolled_window = gtk4::ScrolledWindow::builder()
|
||||
.hscrollbar_policy(gtk4::PolicyType::Never)
|
||||
.vscrollbar_policy(gtk4::PolicyType::Automatic)
|
||||
.vexpand(true)
|
||||
.child(&list_box)
|
||||
.build();
|
||||
stack.add_named(&scrolled_window, Some("search"));
|
||||
|
||||
// Page 2: Error screen if daemon is offline
|
||||
let error_box = gtk4::Box::new(gtk4::Orientation::Vertical, 12);
|
||||
error_box.add_css_class("error-box");
|
||||
error_box.set_valign(gtk4::Align::Center);
|
||||
error_box.set_halign(gtk4::Align::Center);
|
||||
|
||||
let error_icon = gtk4::Image::from_icon_name("dialog-warning-symbolic");
|
||||
error_icon.set_pixel_size(48);
|
||||
error_icon.add_css_class("brand-icon");
|
||||
error_box.append(&error_icon);
|
||||
|
||||
let error_title = gtk4::Label::builder()
|
||||
.label("Daemon Offline")
|
||||
.justify(gtk4::Justification::Center)
|
||||
.build();
|
||||
error_title.add_css_class("error-title");
|
||||
error_box.append(&error_title);
|
||||
|
||||
let error_desc = gtk4::Label::builder()
|
||||
.label("Proton Drive mount daemon is not running.\nStart the daemon or run `pdfs mount` to connect.")
|
||||
.justify(gtk4::Justification::Center)
|
||||
.build();
|
||||
error_desc.add_css_class("dim-label");
|
||||
error_box.append(&error_desc);
|
||||
|
||||
let retry_button = gtk4::Button::builder()
|
||||
.label("Retry Connection")
|
||||
.halign(gtk4::Align::Center)
|
||||
.build();
|
||||
retry_button.add_css_class("suggested-action");
|
||||
retry_button.add_css_class("pill");
|
||||
error_box.append(&retry_button);
|
||||
|
||||
stack.add_named(&error_box, Some("error"));
|
||||
|
||||
// Footer box: status label
|
||||
let footer_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 8);
|
||||
footer_box.add_css_class("launcher-footer");
|
||||
|
||||
let status_label = gtk4::Label::builder()
|
||||
.label("Initialising...")
|
||||
.halign(gtk4::Align::Start)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
status_label.add_css_class("dim-label");
|
||||
footer_box.append(&status_label);
|
||||
|
||||
container.append(&footer_box);
|
||||
|
||||
// Shared state variables
|
||||
let hits = Rc::new(RefCell::new(Vec::<SearchHit>::new()));
|
||||
let mountpoint = Rc::new(RefCell::new(default_mountpoint.display().to_string()));
|
||||
let is_opening = Rc::new(Cell::new(false));
|
||||
|
||||
// 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 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_conn = check_connection.clone();
|
||||
retry_button.connect_clicked(move |_| {
|
||||
check_conn();
|
||||
});
|
||||
|
||||
window.connect_is_active_notify(move |w| {
|
||||
if !w.is_active() {
|
||||
w.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Auto-focus search entry on window show
|
||||
let entry_focus = entry.clone();
|
||||
window.connect_map(move |_| {
|
||||
entry_focus.grab_focus();
|
||||
});
|
||||
|
||||
// Event key controller on entry field for keyboard shortcuts
|
||||
let key_controller = gtk4::EventControllerKey::new();
|
||||
let list_box_keys = list_box.clone();
|
||||
let window_keys = window.clone();
|
||||
let entry_keys = entry.clone();
|
||||
let hits_keys = hits.clone();
|
||||
let is_opening_keys = is_opening.clone();
|
||||
|
||||
key_controller.connect_key_pressed(move |_, key, _keycode, _state| {
|
||||
if is_opening_keys.get() {
|
||||
return glib::Propagation::Stop;
|
||||
}
|
||||
|
||||
match key {
|
||||
gtk4::gdk::Key::Escape => {
|
||||
window_keys.close();
|
||||
glib::Propagation::Stop
|
||||
}
|
||||
gtk4::gdk::Key::Down => {
|
||||
let count = hits_keys.borrow().len();
|
||||
if count > 0 {
|
||||
let selected = list_box_keys.selected_row();
|
||||
let next_idx = if let Some(row) = selected {
|
||||
(row.index() + 1) % count as i32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
if let Some(row) = list_box_keys.row_at_index(next_idx) {
|
||||
list_box_keys.select_row(Some(&row));
|
||||
row.grab_focus();
|
||||
entry_keys.grab_focus();
|
||||
}
|
||||
}
|
||||
glib::Propagation::Stop
|
||||
}
|
||||
gtk4::gdk::Key::Up => {
|
||||
let count = hits_keys.borrow().len();
|
||||
if count > 0 {
|
||||
let selected = list_box_keys.selected_row();
|
||||
let next_idx = if let Some(row) = selected {
|
||||
(row.index() - 1 + count as i32) % count as i32
|
||||
} else {
|
||||
(count - 1) as i32
|
||||
};
|
||||
if let Some(row) = list_box_keys.row_at_index(next_idx) {
|
||||
list_box_keys.select_row(Some(&row));
|
||||
row.grab_focus();
|
||||
entry_keys.grab_focus();
|
||||
}
|
||||
}
|
||||
glib::Propagation::Stop
|
||||
}
|
||||
_ => glib::Propagation::Proceed,
|
||||
}
|
||||
});
|
||||
entry.add_controller(key_controller);
|
||||
|
||||
// Entry activation (pressing Return/Enter)
|
||||
let list_box_activate = list_box.clone();
|
||||
let hits_activate = hits.clone();
|
||||
let window_activate = window.clone();
|
||||
let socket_activate = socket.clone();
|
||||
let mountpoint_activate = mountpoint.clone();
|
||||
let status_label_activate = status_label.clone();
|
||||
let spinner_activate = spinner.clone();
|
||||
let is_opening_activate = is_opening.clone();
|
||||
|
||||
entry.connect_activate(move |_| {
|
||||
if is_opening_activate.get() {
|
||||
return;
|
||||
}
|
||||
if let Some(row) = list_box_activate.selected_row() {
|
||||
let idx = row.index() as usize;
|
||||
let hits_borrow = hits_activate.borrow();
|
||||
if idx < hits_borrow.len() {
|
||||
let hit = &hits_borrow[idx];
|
||||
open_hit(
|
||||
&window_activate,
|
||||
hit,
|
||||
&socket_activate,
|
||||
&mountpoint_activate,
|
||||
&status_label_activate,
|
||||
&spinner_activate,
|
||||
is_opening_activate.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// List box row activation (mouse clicks / touch taps)
|
||||
let hits_act = hits.clone();
|
||||
let window_act = window.clone();
|
||||
let socket_act = socket.clone();
|
||||
let mountpoint_act = mountpoint.clone();
|
||||
let status_label_act = status_label.clone();
|
||||
let spinner_act = spinner.clone();
|
||||
let is_opening_act = is_opening.clone();
|
||||
|
||||
list_box.connect_row_activated(move |_, row| {
|
||||
if is_opening_act.get() {
|
||||
return;
|
||||
}
|
||||
let idx = row.index() as usize;
|
||||
let hits_borrow = hits_act.borrow();
|
||||
if idx < hits_borrow.len() {
|
||||
let hit = &hits_borrow[idx];
|
||||
open_hit(
|
||||
&window_act,
|
||||
hit,
|
||||
&socket_act,
|
||||
&mountpoint_act,
|
||||
&status_label_act,
|
||||
&spinner_act,
|
||||
is_opening_act.clone(),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// 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 socket_search = socket.clone();
|
||||
let status_label_search = status_label.clone();
|
||||
let spinner_search = spinner.clone();
|
||||
|
||||
entry.connect_changed(move |ent| {
|
||||
if let Some(src) = search_source_changed.borrow_mut().take() {
|
||||
src.remove();
|
||||
}
|
||||
|
||||
let ent_clone = ent.clone();
|
||||
let list_box_clone = list_box_search.clone();
|
||||
let hits_clone = hits_search.clone();
|
||||
let socket_clone = socket_search.clone();
|
||||
let status_label_clone = status_label_search.clone();
|
||||
let spinner_clone = spinner_search.clone();
|
||||
let search_source_cb = search_source_changed.clone();
|
||||
|
||||
let src = glib::timeout_add_local_once(SEARCH_DEBOUNCE, move || {
|
||||
search_source_cb.borrow_mut().take();
|
||||
let query = ent_clone.text().trim().to_string();
|
||||
if query.is_empty() {
|
||||
load_initial_pins(
|
||||
&list_box_clone,
|
||||
&hits_clone,
|
||||
&socket_clone,
|
||||
&status_label_clone,
|
||||
&spinner_clone,
|
||||
);
|
||||
} else {
|
||||
run_prompt_search(
|
||||
&list_box_clone,
|
||||
&hits_clone,
|
||||
&socket_clone,
|
||||
&query,
|
||||
&status_label_clone,
|
||||
&spinner_clone,
|
||||
);
|
||||
}
|
||||
});
|
||||
*search_source_changed.borrow_mut() = Some(src);
|
||||
});
|
||||
|
||||
// Perform the initial connection and load check
|
||||
check_connection();
|
||||
|
||||
window.present();
|
||||
}
|
||||
|
||||
/// 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>>>,
|
||||
socket: &Path,
|
||||
status_label: >k4::Label,
|
||||
spinner: >k4::Spinner,
|
||||
) {
|
||||
status_label.set_text("Loading pinned files...");
|
||||
spinner.set_visible(true);
|
||||
spinner.start();
|
||||
|
||||
let rx = spawn_request(socket.to_path_buf(), Request::ListPins);
|
||||
let list_box = list_box.clone();
|
||||
let hits = hits.clone();
|
||||
let status_label = status_label.clone();
|
||||
let spinner = spinner.clone();
|
||||
|
||||
glib::spawn_future_local(async move {
|
||||
let res = rx.recv().await;
|
||||
spinner.stop();
|
||||
spinner.set_visible(false);
|
||||
|
||||
match res {
|
||||
Ok(Ok(Response::Pins { pins })) => {
|
||||
let mapped_hits: Vec<SearchHit> = pins
|
||||
.into_iter()
|
||||
.map(|p| {
|
||||
let path_obj = Path::new(&p.path);
|
||||
let name = path_obj
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or(&p.path)
|
||||
.to_string();
|
||||
SearchHit {
|
||||
name,
|
||||
path: p.path,
|
||||
is_dir: p.recursive,
|
||||
size: 0,
|
||||
modified: 0,
|
||||
pinned: true,
|
||||
uid: p.uid,
|
||||
}
|
||||
})
|
||||
.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.");
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
status_label.set_text("Failed to load pinned files. Type to search.");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Run search on the daemon and update the results list.
|
||||
fn run_prompt_search(
|
||||
list_box: >k4::ListBox,
|
||||
hits: &Rc<RefCell<Vec<SearchHit>>>,
|
||||
socket: &Path,
|
||||
query: &str,
|
||||
status_label: >k4::Label,
|
||||
spinner: >k4::Spinner,
|
||||
) {
|
||||
status_label.set_text("Searching...");
|
||||
spinner.set_visible(true);
|
||||
spinner.start();
|
||||
|
||||
let rx = spawn_request(
|
||||
socket.to_path_buf(),
|
||||
Request::Search {
|
||||
query: query.to_string(),
|
||||
limit: 30,
|
||||
},
|
||||
);
|
||||
let list_box = list_box.clone();
|
||||
let hits = hits.clone();
|
||||
let status_label = status_label.clone();
|
||||
let spinner = spinner.clone();
|
||||
|
||||
glib::spawn_future_local(async move {
|
||||
let res = rx.recv().await;
|
||||
spinner.stop();
|
||||
spinner.set_visible(false);
|
||||
|
||||
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()));
|
||||
}
|
||||
}
|
||||
Ok(Ok(Response::Error { message })) => {
|
||||
status_label.set_text(&format!("Search error: {message}"));
|
||||
}
|
||||
_ => {
|
||||
status_label.set_text("Search request failed.");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Render the provided search hits into the ListBox.
|
||||
fn repaint_prompt_results(
|
||||
list_box: >k4::ListBox,
|
||||
hits: &Rc<RefCell<Vec<SearchHit>>>,
|
||||
new_hits: &[SearchHit],
|
||||
) {
|
||||
// Clear all rows
|
||||
while let Some(child) = list_box.first_child() {
|
||||
list_box.remove(&child);
|
||||
}
|
||||
|
||||
*hits.borrow_mut() = new_hits.to_vec();
|
||||
|
||||
for hit in new_hits {
|
||||
let row = gtk4::ListBoxRow::new();
|
||||
row.add_css_class("launcher-row");
|
||||
|
||||
let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, 12);
|
||||
|
||||
let icon_name = if hit.is_dir {
|
||||
"folder-symbolic"
|
||||
} else {
|
||||
"document-symbolic"
|
||||
};
|
||||
let icon = gtk4::Image::from_icon_name(icon_name);
|
||||
row_box.append(&icon);
|
||||
|
||||
let text_box = gtk4::Box::new(gtk4::Orientation::Vertical, 2);
|
||||
text_box.set_hexpand(true);
|
||||
|
||||
let name_label = gtk4::Label::builder()
|
||||
.label(&hit.name)
|
||||
.halign(gtk4::Align::Start)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
|
||||
let path_label = gtk4::Label::builder()
|
||||
.label(&hit.path)
|
||||
.halign(gtk4::Align::Start)
|
||||
.ellipsize(gtk4::pango::EllipsizeMode::End)
|
||||
.build();
|
||||
path_label.add_css_class("dim-label");
|
||||
|
||||
text_box.append(&name_label);
|
||||
text_box.append(&path_label);
|
||||
|
||||
row_box.append(&text_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) {
|
||||
list_box.select_row(Some(&row));
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the selected search hit. Directories open FUSE directly, files trigger cache download.
|
||||
fn open_hit(
|
||||
window: &adw::ApplicationWindow,
|
||||
hit: &SearchHit,
|
||||
socket: &Path,
|
||||
mountpoint: &Rc<RefCell<String>>,
|
||||
status_label: >k4::Label,
|
||||
spinner: >k4::Spinner,
|
||||
is_opening: Rc<Cell<bool>>,
|
||||
) {
|
||||
if hit.is_dir {
|
||||
let mp = mountpoint.borrow();
|
||||
let full_path = Path::new(&*mp).join(&hit.path);
|
||||
tracing::info!("Opening directory: {}", full_path.display());
|
||||
if let Err(e) = Command::new("xdg-open").arg(&full_path).spawn() {
|
||||
tracing::error!("Failed to xdg-open directory: {e}");
|
||||
}
|
||||
window.close();
|
||||
} else {
|
||||
is_opening.set(true);
|
||||
status_label.set_text(&format!("Downloading {}...", hit.name));
|
||||
spinner.set_visible(true);
|
||||
spinner.start();
|
||||
|
||||
let rx = spawn_request(
|
||||
socket.to_path_buf(),
|
||||
Request::OpenFile {
|
||||
path: hit.path.clone(),
|
||||
},
|
||||
);
|
||||
let window_clone = window.clone();
|
||||
let status_label_clone = status_label.clone();
|
||||
let spinner_clone = spinner.clone();
|
||||
let is_opening_clone = is_opening.clone();
|
||||
|
||||
glib::spawn_future_local(async move {
|
||||
let res = rx.recv().await;
|
||||
spinner_clone.stop();
|
||||
spinner_clone.set_visible(false);
|
||||
is_opening_clone.set(false);
|
||||
|
||||
match res {
|
||||
Ok(Ok(Response::FilePath { path })) => {
|
||||
tracing::info!("Opening file: {}", path);
|
||||
if let Err(e) = Command::new("xdg-open").arg(&path).spawn() {
|
||||
tracing::error!("Failed to xdg-open file: {e}");
|
||||
}
|
||||
window_clone.close();
|
||||
}
|
||||
Ok(Ok(Response::Error { message })) => {
|
||||
status_label_clone.set_text(&format!("Error: {message}"));
|
||||
}
|
||||
_ => {
|
||||
status_label_clone.set_text("Error: Failed to communicate with daemon.");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -43,7 +43,8 @@ build() {
|
||||
cargo build --release --frozen \
|
||||
--bin pdfs \
|
||||
--bin pdfs-tray \
|
||||
--bin pdfs-app
|
||||
--bin pdfs-app \
|
||||
--bin pdfs-prompt
|
||||
}
|
||||
|
||||
package() {
|
||||
@@ -53,6 +54,7 @@ package() {
|
||||
install -Dm755 "$rel/pdfs" "$pkgdir/usr/bin/pdfs"
|
||||
install -Dm755 "$rel/pdfs-tray" "$pkgdir/usr/bin/pdfs-tray"
|
||||
install -Dm755 "$rel/pdfs-app" "$pkgdir/usr/bin/pdfs-app"
|
||||
install -Dm755 "$rel/pdfs-prompt" "$pkgdir/usr/bin/pdfs-prompt"
|
||||
|
||||
install -Dm644 "packaging/io.narl.proton-drive-linux.desktop" \
|
||||
"$pkgdir/usr/share/applications/io.narl.proton-drive-linux.desktop"
|
||||
|
||||
Reference in new issue
Block a user