mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-04 16:56:59 +01:00
The launcher (pdfs-prompt) now follows the app's theme: the theme code moved into a shared theme.rs, the prompt's stylesheet lives in resources/prompt.css on the libadwaita palette, the Proton-purple accent pin is gone, and every activation re-reads the Proton theme and open_with settings so changes in the app apply without restarting the launcher. The window is rebuilt in src/hud/: - one list with Recent, Pinned and Best matches sections; a click selects, a double-click or Enter opens, focus stays in the entry - rows show a thumbnail, the name with its matched characters in bold, location, sync badge, size and age - a preview pane with a large thumbnail, type, size, modified date, location and availability, plus Open (Enter), Show in Folder (Ctrl+Enter, via FileManager1) and Copy Path (Ctrl+C / Ctrl+Shift+C) - thumbnails from the freedesktop cache, local decoding (written back to the shared cache) or the daemon, on two worker threads - a history of opened files that fills the Recent section and adds a frecency bonus to search ranking - the mount check before opening a Drive file runs off the main thread with a 2 s timeout, and Escape cancels a slow open - rows can be dragged out as files - the search debounce drops to 80 ms New strings are translated in every catalog.
364 lines
12 KiB
Rust
364 lines
12 KiB
Rust
//! Recent files and frecency.
|
|
//!
|
|
//! The launcher remembers what it opened in `prompt-history.json` under the
|
|
//! state directory. With an empty query those files come first, and while
|
|
//! searching a file opened often and lately gets a bonus on top of its match
|
|
//! score (see [`frecency_bonus`]).
|
|
//!
|
|
//! One writer thread owns the file: it loads it, applies every recorded visit
|
|
//! in order and writes the result back atomically. A visit recorded before the
|
|
//! first load finished simply waits in the channel, so it is merged into the
|
|
//! loaded list rather than overwriting it. The main thread only ever sees
|
|
//! snapshots.
|
|
|
|
use std::cell::RefCell;
|
|
use std::collections::HashMap;
|
|
use std::io::Write;
|
|
use std::path::{Path, PathBuf};
|
|
use std::rc::Rc;
|
|
|
|
use gtk4::glib;
|
|
|
|
use crate::Hit;
|
|
|
|
/// How many files the history keeps. More than the launcher ever shows, so
|
|
/// frecency still knows a file that dropped off the recent list.
|
|
const MAX_VISITS: usize = 50;
|
|
|
|
/// The frecency bonus of one visit made just now.
|
|
const VISIT_BONUS: f64 = 1500.0;
|
|
|
|
/// The bonus halves every this many days without a visit.
|
|
const HALF_LIFE_DAYS: f64 = 14.0;
|
|
|
|
/// The largest frecency bonus. A name match is worth up to 20000 points, so
|
|
/// history breaks ties between similar matches but cannot beat a much better
|
|
/// one.
|
|
const MAX_BONUS: f64 = 6000.0;
|
|
|
|
#[derive(Clone, serde::Serialize, serde::Deserialize)]
|
|
pub(crate) struct Visit {
|
|
pub(crate) hit: Hit,
|
|
/// When the file was last opened, in epoch seconds.
|
|
pub(crate) last: i64,
|
|
pub(crate) count: u32,
|
|
}
|
|
|
|
/// The history bonus for a file opened `count` times, the last time
|
|
/// `age_secs` ago.
|
|
pub(crate) fn frecency_bonus(count: u32, age_secs: i64) -> i64 {
|
|
let age_days = age_secs.max(0) as f64 / 86_400.0;
|
|
let bonus = f64::from(count) * VISIT_BONUS * 0.5f64.powf(age_days / HALF_LIFE_DAYS);
|
|
bonus.min(MAX_BONUS) as i64
|
|
}
|
|
|
|
/// Count one more visit of `hit` at `now`, newest first, and keep at most
|
|
/// [`MAX_VISITS`]. The stored hit is replaced, so its size, date and sync
|
|
/// state are the ones from the latest search.
|
|
fn record(visits: &mut Vec<Visit>, hit: Hit, now: i64) {
|
|
let key = hit.key();
|
|
let count = match visits.iter().position(|visit| visit.hit.key() == key) {
|
|
Some(at) => visits.remove(at).count.saturating_add(1),
|
|
None => 1,
|
|
};
|
|
visits.insert(
|
|
0,
|
|
Visit {
|
|
hit,
|
|
last: now,
|
|
count,
|
|
},
|
|
);
|
|
visits.truncate(MAX_VISITS);
|
|
}
|
|
|
|
/// Drop local files that no longer exist. Drive files stay: checking them
|
|
/// would touch the mount, and a stale one only costs a row.
|
|
fn prune(visits: &mut Vec<Visit>) -> bool {
|
|
let before = visits.len();
|
|
visits.retain(|visit| match &visit.hit {
|
|
Hit::Local(hit) => Path::new(&hit.path).symlink_metadata().is_ok(),
|
|
Hit::Drive(_) => true,
|
|
});
|
|
visits.len() != before
|
|
}
|
|
|
|
fn load(path: &Path) -> Vec<Visit> {
|
|
match std::fs::read(path) {
|
|
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
|
|
tracing::warn!("ignoring unreadable {}: {e}", path.display());
|
|
Vec::new()
|
|
}),
|
|
Err(_) => Vec::new(),
|
|
}
|
|
}
|
|
|
|
/// Write the history through a temporary file and a rename, so a crash
|
|
/// mid-write leaves the old file rather than half a new one.
|
|
fn save(path: &Path, visits: &[Visit]) -> std::io::Result<()> {
|
|
let json = serde_json::to_vec(visits).map_err(std::io::Error::other)?;
|
|
if let Some(dir) = path.parent() {
|
|
std::fs::create_dir_all(dir)?;
|
|
}
|
|
let tmp = path.with_extension("json.tmp");
|
|
let mut file = std::fs::File::create(&tmp)?;
|
|
file.write_all(&json)?;
|
|
file.sync_all()?;
|
|
std::fs::rename(&tmp, path)
|
|
}
|
|
|
|
enum Command {
|
|
Record(Hit, i64),
|
|
Prune,
|
|
}
|
|
|
|
/// The writer thread's loop: load, then apply commands until the launcher
|
|
/// exits. `publish` receives the list after the load and after every change.
|
|
fn serve(path: &Path, commands: &async_channel::Receiver<Command>, publish: impl Fn(Vec<Visit>)) {
|
|
let mut visits = load(path);
|
|
let pruned = prune(&mut visits);
|
|
publish(visits.clone());
|
|
if pruned && let Err(e) = save(path, &visits) {
|
|
tracing::warn!("cannot write {}: {e}", path.display());
|
|
}
|
|
while let Ok(command) = commands.recv_blocking() {
|
|
let changed = match command {
|
|
Command::Record(hit, now) => {
|
|
record(&mut visits, hit, now);
|
|
true
|
|
}
|
|
Command::Prune => prune(&mut visits),
|
|
};
|
|
if changed {
|
|
if let Err(e) = save(path, &visits) {
|
|
tracing::warn!("cannot write {}: {e}", path.display());
|
|
}
|
|
publish(visits.clone());
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The main thread's view of the history.
|
|
pub(crate) struct History {
|
|
visits: Rc<RefCell<Vec<Visit>>>,
|
|
commands: async_channel::Sender<Command>,
|
|
}
|
|
|
|
impl History {
|
|
/// Start the writer thread. `changed` runs on the main thread whenever a
|
|
/// new snapshot arrives.
|
|
pub(crate) fn start(path: PathBuf, changed: impl Fn() + 'static) -> Self {
|
|
let (commands, inbox) = async_channel::unbounded();
|
|
let (snapshots, updates) = async_channel::unbounded::<Vec<Visit>>();
|
|
let spawned = std::thread::Builder::new()
|
|
.name("prompt-history".into())
|
|
.spawn(move || {
|
|
serve(&path, &inbox, |visits| {
|
|
let _ = snapshots.send_blocking(visits);
|
|
})
|
|
});
|
|
if let Err(e) = spawned {
|
|
tracing::warn!("cannot start the history writer: {e}");
|
|
}
|
|
|
|
let visits = Rc::new(RefCell::new(Vec::new()));
|
|
let shared = visits.clone();
|
|
glib::spawn_future_local(async move {
|
|
while let Ok(snapshot) = updates.recv().await {
|
|
*shared.borrow_mut() = snapshot;
|
|
changed();
|
|
}
|
|
});
|
|
Self { visits, commands }
|
|
}
|
|
|
|
/// Remember that `hit` was opened just now.
|
|
pub(crate) fn record(&self, hit: Hit) {
|
|
let _ = self.commands.try_send(Command::Record(hit, now()));
|
|
}
|
|
|
|
/// Forget local files that were deleted since they were opened.
|
|
pub(crate) fn prune(&self) {
|
|
let _ = self.commands.try_send(Command::Prune);
|
|
}
|
|
|
|
/// The most recently opened files, newest first.
|
|
pub(crate) fn recent(&self, limit: usize) -> Vec<Hit> {
|
|
self.visits
|
|
.borrow()
|
|
.iter()
|
|
.take(limit)
|
|
.map(|visit| visit.hit.clone())
|
|
.collect()
|
|
}
|
|
|
|
/// The frecency bonus of every remembered file, by [`Hit::key`].
|
|
pub(crate) fn bonuses(&self) -> HashMap<(bool, String), i64> {
|
|
let now = now();
|
|
self.visits
|
|
.borrow()
|
|
.iter()
|
|
.map(|visit| {
|
|
(
|
|
visit.hit.key(),
|
|
frecency_bonus(visit.count, now - visit.last),
|
|
)
|
|
})
|
|
.collect()
|
|
}
|
|
}
|
|
|
|
fn now() -> i64 {
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map_or(0, |d| d.as_secs() as i64)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use pdfs_core::control::{LocalHit, SearchHit};
|
|
|
|
const DAY: i64 = 86_400;
|
|
|
|
fn drive(name: &str) -> Hit {
|
|
Hit::Drive(SearchHit {
|
|
name: name.into(),
|
|
path: format!("Docs/{name}"),
|
|
is_dir: false,
|
|
size: 1,
|
|
modified: 1,
|
|
pinned: false,
|
|
cached: false,
|
|
uid: format!("v~{name}"),
|
|
mounted_path: None,
|
|
score: 0,
|
|
})
|
|
}
|
|
|
|
fn scratch_dir(test: &str) -> PathBuf {
|
|
let dir = std::env::temp_dir().join(format!(
|
|
"pdfs-prompt-history-{test}-{}-{}",
|
|
std::process::id(),
|
|
now()
|
|
));
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
dir
|
|
}
|
|
|
|
#[test]
|
|
fn the_bonus_halves_every_two_weeks() {
|
|
assert_eq!(frecency_bonus(1, 0), 1500);
|
|
assert_eq!(frecency_bonus(1, 14 * DAY), 750);
|
|
assert_eq!(frecency_bonus(1, 28 * DAY), 375);
|
|
assert_eq!(frecency_bonus(2, 14 * DAY), 1500);
|
|
// A clock that went backwards counts as "just now", not as a boost.
|
|
assert_eq!(frecency_bonus(1, -DAY), 1500);
|
|
}
|
|
|
|
#[test]
|
|
fn the_bonus_is_capped() {
|
|
assert_eq!(frecency_bonus(4, 0), 6000);
|
|
assert_eq!(frecency_bonus(1000, 0), 6000);
|
|
assert!(frecency_bonus(1000, 365 * DAY) < 6000);
|
|
}
|
|
|
|
#[test]
|
|
fn a_repeat_visit_moves_the_file_to_the_top_and_counts_it() {
|
|
let mut visits = Vec::new();
|
|
record(&mut visits, drive("a.pdf"), 10);
|
|
record(&mut visits, drive("b.pdf"), 20);
|
|
record(&mut visits, drive("a.pdf"), 30);
|
|
assert_eq!(visits.len(), 2);
|
|
assert_eq!(visits[0].hit.name(), "a.pdf");
|
|
assert_eq!((visits[0].count, visits[0].last), (2, 30));
|
|
assert_eq!(visits[1].count, 1);
|
|
}
|
|
|
|
#[test]
|
|
fn the_history_keeps_the_newest_fifty_files() {
|
|
let mut visits = Vec::new();
|
|
for n in 0..60 {
|
|
record(&mut visits, drive(&format!("{n}.pdf")), n);
|
|
}
|
|
assert_eq!(visits.len(), MAX_VISITS);
|
|
assert_eq!(visits[0].hit.name(), "59.pdf");
|
|
assert_eq!(visits[MAX_VISITS - 1].hit.name(), "10.pdf");
|
|
}
|
|
|
|
#[test]
|
|
fn the_history_file_round_trips() {
|
|
let dir = scratch_dir("round-trip");
|
|
let path = dir.join("prompt-history.json");
|
|
let mut visits = Vec::new();
|
|
record(&mut visits, drive("a.pdf"), 10);
|
|
record(&mut visits, drive("a.pdf"), 20);
|
|
save(&path, &visits).unwrap();
|
|
|
|
let loaded = load(&path);
|
|
assert_eq!(loaded.len(), 1);
|
|
assert_eq!(loaded[0].hit.key(), drive("a.pdf").key());
|
|
assert_eq!((loaded[0].count, loaded[0].last), (2, 20));
|
|
assert!(!path.with_extension("json.tmp").exists());
|
|
std::fs::remove_dir_all(dir).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn visits_recorded_before_the_load_are_merged_into_it() {
|
|
let dir = scratch_dir("merge");
|
|
let path = dir.join("prompt-history.json");
|
|
let mut stored = Vec::new();
|
|
record(&mut stored, drive("old.pdf"), 10);
|
|
record(&mut stored, drive("old.pdf"), 20);
|
|
save(&path, &stored).unwrap();
|
|
|
|
// Both visits are queued before the writer has read the file.
|
|
let (commands, inbox) = async_channel::unbounded();
|
|
commands
|
|
.send_blocking(Command::Record(drive("old.pdf"), 30))
|
|
.unwrap();
|
|
commands
|
|
.send_blocking(Command::Record(drive("new.pdf"), 40))
|
|
.unwrap();
|
|
drop(commands);
|
|
|
|
let published = RefCell::new(Vec::new());
|
|
serve(&path, &inbox, |visits| published.borrow_mut().push(visits));
|
|
|
|
let last = published.borrow().last().cloned().unwrap();
|
|
assert_eq!(last.len(), 2);
|
|
assert_eq!(last[0].hit.name(), "new.pdf");
|
|
assert_eq!((last[1].hit.name(), last[1].count), ("old.pdf", 3));
|
|
let on_disk = load(&path);
|
|
assert_eq!(on_disk.len(), 2);
|
|
assert_eq!(on_disk[1].count, 3);
|
|
std::fs::remove_dir_all(dir).unwrap();
|
|
}
|
|
|
|
#[test]
|
|
fn pruning_drops_deleted_local_files_only() {
|
|
let dir = scratch_dir("prune");
|
|
let kept = dir.join("kept.txt");
|
|
std::fs::write(&kept, "x").unwrap();
|
|
let local = |path: &Path| {
|
|
Hit::Local(LocalHit {
|
|
name: path.file_name().unwrap().to_string_lossy().into_owned(),
|
|
path: path.display().to_string(),
|
|
is_dir: false,
|
|
size: 1,
|
|
modified: 1,
|
|
score: 0,
|
|
})
|
|
};
|
|
let mut visits = Vec::new();
|
|
record(&mut visits, local(&kept), 1);
|
|
record(&mut visits, local(&dir.join("gone.txt")), 2);
|
|
record(&mut visits, drive("remote.pdf"), 3);
|
|
|
|
assert!(prune(&mut visits));
|
|
let names: Vec<&str> = visits.iter().map(|visit| visit.hit.name()).collect();
|
|
assert_eq!(names, ["remote.pdf", "kept.txt"]);
|
|
std::fs::remove_dir_all(dir).unwrap();
|
|
}
|
|
}
|