performance fixes

This commit is contained in:
nvrl committed 2026-06-30 05:56:21 +02:00
1 parent 8e8d42ad3b
commit b3e8e8ab96
3 files changed
+128 -52

No files matched your search

+9 -1
View File
@@ -85,11 +85,19 @@ pub struct PhotoItem {
/// The daemon's reply to a [`Request`]. /// The daemon's reply to a [`Request`].
#[derive(Serialize, Deserialize, Debug)] #[derive(Serialize, Deserialize, Debug)]
pub enum Response { pub enum Response {
/// Current mount status. /// Current mount status. Carries the cache stats the daemon already holds
/// (`used`/`budget` bytes and the pin list) so a front-end never has to open
/// the on-disk cache itself on its UI thread.
Status { Status {
username: String, username: String,
mountpoint: String, mountpoint: String,
pinned: usize, pinned: usize,
/// Bytes of cached content blobs (see [`crate::cache::ContentCache::usage`]).
used: u64,
/// Configured soft byte cap (`0` = unlimited).
budget: u64,
/// The pin registry.
pins: Vec<Pin>,
}, },
/// A human-readable success message. /// A human-readable success message.
Ok { message: String }, Ok { message: String },
+9 -3
View File
@@ -1571,11 +1571,17 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
return; return;
} }
let response = match serde_json::from_str::<CtlRequest>(line.trim()) { let response = match serde_json::from_str::<CtlRequest>(line.trim()) {
Ok(CtlRequest::Status) => CtlResponse::Status { Ok(CtlRequest::Status) => {
let pins = core.cache.list_pins();
CtlResponse::Status {
username: username.to_string(), username: username.to_string(),
mountpoint: mountpoint.display().to_string(), mountpoint: mountpoint.display().to_string(),
pinned: core.cache.list_pins().len(), pinned: pins.len(),
}, used: core.cache.usage(),
budget: core.cache.budget(),
pins,
}
}
Ok(CtlRequest::Pin { path }) => match rel_to_mount(mountpoint, &path) { Ok(CtlRequest::Pin { path }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.pin(&rel) { Ok(rel) => match core.pin(&rel) {
Ok(name) => CtlResponse::Ok { Ok(name) => CtlResponse::Ok {
+110 -48
View File
@@ -8,13 +8,14 @@
//! * a **login form** (email / password / optional 2FA) driving //! * a **login form** (email / password / optional 2FA) driving
//! [`pdfs_core::auth::login`]; logging in enables the systemd mount service; //! [`pdfs_core::auth::login`]; logging in enables the systemd mount service;
//! * a **read-only mount status** line (the mount is managed automatically); //! * a **read-only mount status** line (the mount is managed automatically);
//! * a **cache usage** read-out from the on-disk [`ContentCache`]; and //! * a **cache usage** read-out; and
//! * the **pin list** with per-file unpin. //! * the **pin list** with per-file unpin.
//! //!
//! State the GUI only displays (cache size, pins, login identity) is read //! Mount status, cache usage and the pin list all ride along on one
//! straight off disk via `pdfs-core`, so the window is useful even with no mount //! [`Request::Status`] round-trip to the daemon (which owns the cache), fetched on
//! daemon running; live mount status comes from the daemon over the control //! a worker thread so the periodic refresh never blocks the GTK main loop. Login
//! socket the CLI and tray already speak. //! identity is cached in [`Ui`] and refreshed only on login/logout, so the 2s tick
//! never touches the keyring.
use std::cell::{Cell, RefCell}; use std::cell::{Cell, RefCell};
use std::collections::HashSet; use std::collections::HashSet;
@@ -29,8 +30,7 @@ use gtk4::glib;
use gtk4::glib::BoxedAnyObject; use gtk4::glib::BoxedAnyObject;
use pdfs_core::auth; use pdfs_core::auth;
use pdfs_core::cache::ContentCache; use pdfs_core::config::AppDirs;
use pdfs_core::config::{AppDirs, DEFAULT_CACHE_BUDGET_BYTES};
use pdfs_core::control::{DirEntry, PhotoItem, Request, Response, send}; use pdfs_core::control::{DirEntry, PhotoItem, Request, Response, send};
use pdfs_core::service; use pdfs_core::service;
@@ -47,6 +47,14 @@ const REFRESH_INTERVAL: Duration = Duration::from_secs(2);
/// is still coming up (see [`load_browser`] / [`load_gallery`]). /// is still coming up (see [`load_browser`] / [`load_gallery`]).
const CONNECT_RETRY_INTERVAL: Duration = Duration::from_secs(2); const CONNECT_RETRY_INTERVAL: Duration = Duration::from_secs(2);
/// One rendered pin row, retained so [`repaint_pins`] can flip the unpin button's
/// `sensitive` in place (when the pin set is unchanged) instead of rebuilding.
struct PinRow {
row: adw::ActionRow,
/// The unpin button, absent on the placeholder row.
unpin: Option<gtk4::Button>,
}
/// All widgets the periodic refresh and the action handlers mutate, plus the /// All widgets the periodic refresh and the action handlers mutate, plus the
/// resolved paths they act on. Wrapped in an [`Rc`] so handlers and the timeout /// resolved paths they act on. Wrapped in an [`Rc`] so handlers and the timeout
/// closure share one instance. /// closure share one instance.
@@ -60,6 +68,13 @@ struct Ui {
/// Keys (relative path / photo uid) of open requests currently in flight, so /// Keys (relative path / photo uid) of open requests currently in flight, so
/// a double-click on the same entry is a no-op instead of a second download. /// a double-click on the same entry is a no-op instead of a second download.
opening: RefCell<HashSet<String>>, opening: RefCell<HashSet<String>>,
/// Resolved login identity, cached so the periodic [`refresh`] never hits the
/// keyring (a DBus round-trip). Populated at startup and updated only on
/// login / logout. `None` = signed out.
session: RefCell<Option<auth::StoredSession>>,
/// Whether a [`Request::Status`] round-trip is already in flight, so the 2s
/// refresh tick doesn't pile worker threads up on a slow/wedged daemon.
status_inflight: Cell<bool>,
// Login page. // Login page.
email: adw::EntryRow, email: adw::EntryRow,
password: adw::PasswordEntryRow, password: adw::PasswordEntryRow,
@@ -74,9 +89,12 @@ struct Ui {
cache_bar: gtk4::ProgressBar, cache_bar: gtk4::ProgressBar,
cache_label: gtk4::Label, cache_label: gtk4::Label,
pins_group: adw::PreferencesGroup, pins_group: adw::PreferencesGroup,
/// Rows currently shown under [`Self::pins_group`], so a refresh can clear /// Rows currently shown under [`Self::pins_group`], retained so a refresh can
/// the previous batch before re-adding. /// diff against them and only rebuild when the pin set actually changes.
pin_rows: RefCell<Vec<adw::ActionRow>>, pin_rows: RefCell<Vec<PinRow>>,
/// The pin paths last rendered, the diff baseline for [`repaint_pins`].
/// `None` = nothing built yet; `Some(empty)` = the placeholder is shown.
pins_state: RefCell<Option<Vec<String>>>,
/// Whether the last refresh saw a live mount daemon. Gates the unpin /// Whether the last refresh saw a live mount daemon. Gates the unpin
/// buttons, which need the daemon to evict + re-hydrate. /// buttons, which need the daemon to evict + re-hydrate.
mounted: RefCell<bool>, mounted: RefCell<bool>,
@@ -213,6 +231,8 @@ fn build_window(app: &adw::Application) {
spinner: spinner.clone(), spinner: spinner.clone(),
busy: Cell::new(0), busy: Cell::new(0),
opening: RefCell::new(HashSet::new()), opening: RefCell::new(HashSet::new()),
session: RefCell::new(auth::load().ok()),
status_inflight: Cell::new(false),
email: login_widgets.0, email: login_widgets.0,
password: login_widgets.1, password: login_widgets.1,
totp: login_widgets.2, totp: login_widgets.2,
@@ -224,6 +244,7 @@ fn build_window(app: &adw::Application) {
cache_label: main_widgets.3, cache_label: main_widgets.3,
pins_group: main_widgets.4, pins_group: main_widgets.4,
pin_rows: RefCell::new(Vec::new()), pin_rows: RefCell::new(Vec::new()),
pins_state: RefCell::new(None),
mounted: RefCell::new(false), mounted: RefCell::new(false),
switcher: switcher.clone(), switcher: switcher.clone(),
browser_model: browser_widgets.0, browser_model: browser_widgets.0,
@@ -485,6 +506,8 @@ fn wire_login(ui: &Rc<Ui>) {
ui.login_status.set_text(""); ui.login_status.set_text("");
ui.password.set_text(""); ui.password.set_text("");
ui.totp.set_text(""); ui.totp.set_text("");
// Cache the new identity so `refresh` never hits the keyring.
*ui.session.borrow_mut() = auth::load().ok();
// Enable+start the mount service now that we have a session. // Enable+start the mount service now that we have a session.
service::enable_start(); service::enable_start();
refresh(&ui); refresh(&ui);
@@ -543,6 +566,7 @@ fn wire_logout(ui: &Rc<Ui>, button: &gtk4::Button) {
if let Err(e) = auth::logout() { if let Err(e) = auth::logout() {
tracing::error!("logout failed: {e}"); tracing::error!("logout failed: {e}");
} }
*ui.session.borrow_mut() = None;
refresh(&ui); refresh(&ui);
}); });
} }
@@ -563,12 +587,16 @@ fn wire_retry(ui: &Rc<Ui>) {
}); });
} }
/// Re-read mount status, login identity, cache usage and the pin list, and /// Repaint the window from the cached login identity, then kick an async mount-
/// repaint the window. Cheap, synchronous local reads — safe on the main loop. /// status fetch. Runs on the 2s tick: the identity check is instant (no keyring),
/// and the status round-trip is offloaded to a worker so the main loop never
/// blocks on a slow or wedged daemon.
fn refresh(ui: &Rc<Ui>) { fn refresh(ui: &Rc<Ui>) {
// Login identity decides which page is shown. // Login identity decides which page is shown. Read the cached session — set
let session = auth::load().ok(); // at startup and on login/logout — never the keyring.
match &session { {
let session = ui.session.borrow();
match session.as_ref() {
Some(s) => { Some(s) => {
// Only pull the user onto "main" when they're sitting on the login // Only pull the user onto "main" when they're sitting on the login
// page; otherwise leave whichever signed-in page they navigated to. // page; otherwise leave whichever signed-in page they navigated to.
@@ -585,31 +613,36 @@ fn refresh(ui: &Rc<Ui>) {
return; return;
} }
} }
}
// Live mount status from the daemon, falling back to "not mounted". refresh_status(ui);
let socket = ui.dirs.control_socket(); }
let mount = match send(&socket, &Request::Status) {
Ok(Response::Status { mountpoint, .. }) => Some(mountpoint),
_ => None,
};
*ui.mounted.borrow_mut() = mount.is_some();
// Read-only status: the service mounts automatically, we only report. /// Fetch mount status + cache stats from the daemon on a worker thread and repaint
ui.mount_row.set_subtitle(&match &mount { /// the mount line, cache bar and pin list on the reply. The daemon owns the cache
Some(mp) => format!("Mounted at {mp}"), /// stats now (`used`/`budget`/`pins` ride along on [`Response::Status`]), so the
None => "Not mounted".into(), /// GUI never opens the on-disk cache itself. Skipped while a fetch is in flight so
}); /// the tick can't stack threads on a stalled daemon.
fn refresh_status(ui: &Rc<Ui>) {
// Cache usage + pins, read straight off disk so they work offline too. if ui.status_inflight.get() {
let cache = ContentCache::open( return;
ui.dirs.content_cache_dir(), }
ui.dirs.pins_path(), ui.status_inflight.set(true);
DEFAULT_CACHE_BUDGET_BYTES, let rx = spawn_request(ui.dirs.control_socket(), Request::Status);
); let ui = ui.clone();
match cache { glib::spawn_future_local(async move {
Ok(cache) => { let result = rx.recv().await;
let used = cache.usage(); ui.status_inflight.set(false);
let budget = cache.budget(); match result {
Ok(Ok(Response::Status {
mountpoint,
used,
budget,
pins,
..
})) => {
*ui.mounted.borrow_mut() = true;
ui.mount_row.set_subtitle(&format!("Mounted at {mountpoint}"));
let fraction = if budget == 0 { let fraction = if budget == 0 {
0.0 0.0
} else { } else {
@@ -621,29 +654,55 @@ fn refresh(ui: &Rc<Ui>) {
human_bytes(used), human_bytes(used),
human_bytes(budget) human_bytes(budget)
)); ));
repaint_pins(ui, &cache.list_pins()); repaint_pins(&ui, &pins, true);
} }
Err(e) => ui.cache_label.set_text(&format!("Cache unavailable: {e}")), // Daemon unreachable (still starting, or down): report not-mounted and
// grey out the unpin buttons in place, but leave the last-known pin
// rows and cache read-out so the page doesn't flicker on a blip.
_ => {
*ui.mounted.borrow_mut() = false;
ui.mount_row.set_subtitle("Not mounted");
for r in ui.pin_rows.borrow().iter() {
if let Some(b) = &r.unpin {
b.set_sensitive(false);
} }
}
}
}
});
} }
/// Replace the rows under the pins group with one [`adw::ActionRow`] per pin, /// Render the pins group from `pins`, with the unpin buttons enabled only while a
/// each with an unpin button enabled only while a mount daemon is running. /// mount daemon is running (`mounted`). Diffs against the last batch by path: when
fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin]) { /// the set is unchanged (the common case on the 2s tick) it only flips the unpin
for row in ui.pin_rows.borrow_mut().drain(..) { /// buttons' `sensitive` flag, avoiding the rebuild that used to flicker the list
ui.pins_group.remove(&row); /// and drop scroll/selection every tick.
fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin], mounted: bool) {
let desired: Vec<String> = pins.iter().map(|p| p.path.clone()).collect();
if ui.pins_state.borrow().as_ref() == Some(&desired) {
for r in ui.pin_rows.borrow().iter() {
if let Some(b) = &r.unpin {
b.set_sensitive(mounted);
} }
}
return;
}
for pr in ui.pin_rows.borrow_mut().drain(..) {
ui.pins_group.remove(&pr.row);
}
*ui.pins_state.borrow_mut() = Some(desired);
if pins.is_empty() { if pins.is_empty() {
let row = adw::ActionRow::builder() let row = adw::ActionRow::builder()
.title("No pinned files") .title("No pinned files")
.subtitle("Right-click a file in the mount to keep it offline.") .subtitle("Right-click a file in the mount to keep it offline.")
.build(); .build();
ui.pins_group.add(&row); ui.pins_group.add(&row);
ui.pin_rows.borrow_mut().push(row); ui.pin_rows.borrow_mut().push(PinRow { row, unpin: None });
return; return;
} }
let mounted = *ui.mounted.borrow();
for pin in pins { for pin in pins {
let name = Path::new(&pin.path) let name = Path::new(&pin.path)
.file_name() .file_name()
@@ -677,7 +736,10 @@ fn repaint_pins(ui: &Rc<Ui>, pins: &[pdfs_core::cache::Pin]) {
row.add_suffix(&unpin); row.add_suffix(&unpin);
ui.pins_group.add(&row); ui.pins_group.add(&row);
ui.pin_rows.borrow_mut().push(row); ui.pin_rows.borrow_mut().push(PinRow {
row,
unpin: Some(unpin),
});
} }
} }