fix(GALLERY) fixed takeout date conversion + repair command

This commit is contained in:
nvrl committed 2026-08-28 13:06:50 +02:00
1 parent 16ad6548c8
commit 99ad3838e1
23 files changed
+2028 -325

No files matched your search

+1
View File
@@ -27,6 +27,7 @@ tracing.workspace = true
tracing-subscriber.workspace = true
anyhow.workspace = true
tokio.workspace = true
serde.workspace = true
serde_json.workspace = true
ksni = "0.2"
gtk4 = { version = "0.9", features = ["v4_12"] }
+3 -2
View File
@@ -24,7 +24,7 @@ use widgets::versions_dialog::*;
use std::cell::{Cell, RefCell};
use std::collections::{HashMap, HashSet, VecDeque};
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
use std::path::{Path, PathBuf};
@@ -484,7 +484,8 @@ fn build_window(app: &adw::Application) {
decode_idle: Cell::new(false),
thumb_source: RefCell::new(None),
relayout_source: RefCell::new(None),
bound: RefCell::new(HashMap::new()),
bound: RefCell::new(BTreeMap::new()),
list: gallery_widgets.list.clone(),
},
shared: SharedState {
content: shared_widgets.content.clone(),
+198 -99
View File
@@ -5,8 +5,12 @@ pub(crate) struct GalleryState {
/// Every photo loaded so far, newest first — the order the lightbox's
/// prev/next walks. The visible day sections are derived from this.
pub(crate) model: gio::ListStore,
/// The day sections rendered by the Photos ListView, rebuilt from
/// [`Self::model`] by [`repaint_gallery`].
/// The Photos ListView's own model: one item per *rendered row* — a day
/// heading, or one row of that day's tile grid — rebuilt from
/// [`Self::model`] by [`repaint_gallery`]. Rows rather than whole days
/// because the ListView only realises what is on screen, and a day holding
/// 1,600 photos (a Takeout import lands one) would otherwise be 1,600
/// widgets built in a single bind.
pub(crate) groups: gio::ListStore,
/// Target tile edge in px, retuned by Ctrl+scroll / Ctrl+± (see
/// [`zoom_gallery`]). The grid fits as many square tiles of about this size
@@ -110,11 +114,14 @@ pub(crate) struct GalleryState {
/// re-flow, replaced on each new trigger so only the last one fires.
pub(crate) thumb_source: RefCell<Option<glib::SourceId>>,
pub(crate) relayout_source: RefCell<Option<glib::SourceId>>,
/// The day sections currently realised by the ListView, by their index in
/// [`Self::groups`]. A resize or a zoom step re-flows these in place —
/// rebuilding the ListStore instead would reset the scroll position out from
/// under the user.
pub(crate) bound: RefCell<HashMap<u32, gtk4::Box>>,
/// The rows currently realised by the ListView, as row index -> the uid of
/// its first photo (absent for a heading row). A resize or a zoom step
/// changes how many tiles fit per row, so the row model has to be rebuilt —
/// this is what lets the rebuild put the user back where they were.
pub(crate) bound: RefCell<BTreeMap<u32, Option<String>>>,
/// The ListView itself, so a rebuild can scroll back to the row the user was
/// looking at.
pub(crate) list: gtk4::ListView,
}
/// How many photos to pull per [`Request::PhotosTimeline`] page.
@@ -158,13 +165,71 @@ pub(crate) const RELAYOUT_DEBOUNCE: Duration = Duration::from_millis(80);
/// One day-section of the photos timeline: a heading plus the photos captured
/// that day, in timeline order. Built from the flat [`Ui::gallery_model`] by
/// [`group_photos`] and rendered as one [`gtk4::ListView`] row.
/// [`group_photos`], then flattened into [`GalleryRow`]s for rendering.
pub(crate) struct PhotoGroup {
/// "Today", "Yesterday", or e.g. "3 June 2026".
pub(crate) heading: String,
pub(crate) photos: Vec<PhotoItem>,
}
/// One item of the ListView's model — the unit the gallery virtualises at.
///
/// A whole day is *not* one item: a Google Photos Takeout drops thousands of
/// photos onto a single date, and one ListView item per day means GTK builds
/// every one of those tiles in one bind, on the main thread, before the row can
/// be shown. Splitting the day into its grid rows keeps a bind to a handful of
/// widgets no matter how big the day is.
pub(crate) enum GalleryRow {
/// A day heading: "Today", "3 June 2026".
Heading(String),
/// One row of a day's grid, already laid out to the current width and zoom.
Tiles {
photos: Vec<PhotoItem>,
edge: i32,
/// True for the last row of its day, which carries the section's bottom
/// margin so days stay visually separated.
last: bool,
},
}
impl GalleryRow {
/// The uid of the row's first photo, or `None` for a heading — the anchor a
/// relayout scrolls back to.
fn anchor(&self) -> Option<String> {
match self {
GalleryRow::Heading(_) => None,
GalleryRow::Tiles { photos, .. } => photos.first().map(|p| p.uid.clone()),
}
}
/// Whether re-rendering `self` would produce exactly what `other` shows —
/// the test [`repaint_gallery`] diffs on, so appending a page only touches
/// the rows that actually changed.
fn same_as(&self, other: &GalleryRow) -> bool {
match (self, other) {
(GalleryRow::Heading(a), GalleryRow::Heading(b)) => a == b,
(
GalleryRow::Tiles {
photos: a,
edge: ae,
last: al,
},
GalleryRow::Tiles {
photos: b,
edge: be,
last: bl,
},
) => {
ae == be
&& al == bl
&& a.len() == b.len()
&& a.iter().zip(b).all(|(x, y)| x.uid == y.uid)
}
_ => false,
}
}
}
/// The widgets [`build_gallery_page`] hands back to [`build_window`].
pub(crate) struct GalleryWidgets {
/// Flat, newest-first list of every loaded photo. Backs the lightbox's
@@ -611,40 +676,64 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: &gtk4::ListView, scroll: &gtk4::Sc
let factory = gtk4::SignalListItemFactory::new();
factory.connect_setup(|_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
let section = gtk4::Box::new(gtk4::Orientation::Vertical, 8);
section.set_margin_bottom(16);
let heading = gtk4::Label::builder().halign(gtk4::Align::Start).build();
heading.add_css_class("heading");
heading.add_css_class("gallery-day");
section.append(&heading);
item.set_child(Some(&section));
// One container for both row kinds: the bind fills it with either a
// heading label or a strip of tiles. The alternative — two factories, or
// a Stack per row — buys nothing, since a row's children are rebuilt on
// bind either way.
let row = gtk4::Box::new(gtk4::Orientation::Horizontal, TILE_GAP);
row.set_halign(gtk4::Align::Start);
item.set_child(Some(&row));
item.set_activatable(false);
});
let ui_bind = ui.clone();
factory.connect_bind(move |_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
let section = item.child().and_downcast::<gtk4::Box>().unwrap();
let row_box = item.child().and_downcast::<gtk4::Box>().unwrap();
let obj = item.item().and_downcast::<BoxedAnyObject>().unwrap();
let group = obj.borrow::<PhotoGroup>();
let row = obj.borrow::<GalleryRow>();
let heading = section.first_child().and_downcast::<gtk4::Label>().unwrap();
heading.set_label(&group.heading);
fill_section(&ui_bind, &section, &group.photos);
// Remember the realised section so a learned aspect ratio or a resize can
// re-justify it in place, without rebuilding the ListStore (which would
// yank the scroll position back to the top).
while let Some(child) = row_box.first_child() {
row_box.remove(&child);
}
match &*row {
GalleryRow::Heading(heading) => {
row_box.set_margin_top(16);
row_box.set_margin_bottom(8);
let label = gtk4::Label::builder()
.label(heading)
.halign(gtk4::Align::Start)
.build();
label.add_css_class("heading");
label.add_css_class("gallery-day");
row_box.append(&label);
}
GalleryRow::Tiles { photos, edge, last } => {
row_box.set_margin_top(0);
// The last row of a day carries the gap to the next heading.
row_box.set_margin_bottom(if *last { 8 } else { TILE_GAP });
for photo in photos {
row_box.append(&photo_tile(
&ui_bind,
Tile {
photo: photo.clone(),
edge: *edge,
},
));
}
schedule_thumbs(&ui_bind);
}
}
ui_bind
.gallery
.bound
.borrow_mut()
.insert(item.position(), section);
.insert(item.position(), row.anchor());
});
// ListView recycles section widgets, so a scrolled-away day must give up its
// claim on the widgets — otherwise a thumbnail landing late would paint into
// a tile that now shows a different day.
// ListView recycles row widgets, so a scrolled-away row must give up its
// claim on them — otherwise a thumbnail landing late would paint into a tile
// that now shows a different photo.
let ui_unbind = ui.clone();
factory.connect_unbind(move |_, item| {
let item = item.downcast_ref::<gtk4::ListItem>().unwrap();
@@ -653,10 +742,11 @@ pub(crate) fn wire_gallery(ui: &Rc<Ui>, list: &gtk4::ListView, scroll: &gtk4::Sc
.bound
.borrow_mut()
.remove(&item.position());
if let Some(obj) = item.item().and_downcast::<BoxedAnyObject>() {
let group = obj.borrow::<PhotoGroup>();
if let Some(obj) = item.item().and_downcast::<BoxedAnyObject>()
&& let GalleryRow::Tiles { photos, .. } = &*obj.borrow::<GalleryRow>()
{
let mut wanted = ui_unbind.gallery.thumb_wanted.borrow_mut();
for photo in &group.photos {
for photo in photos {
wanted.remove(&photo.uid);
}
}
@@ -917,34 +1007,6 @@ pub(crate) fn plan_grid(target: i32, width: i32) -> (usize, i32) {
(columns, edge)
}
/// (Re)build a bound day-section's tiles: lay this day's photos out to the
/// current content width and hand each tile whatever thumbnail is already in
/// memory, queueing the rest. Replaces the section's rows in place, leaving the
/// heading — so a re-flow never touches the ListView's model or scroll.
pub(crate) fn fill_section(ui: &Rc<Ui>, section: &gtk4::Box, photos: &[PhotoItem]) {
let Some(heading) = section.first_child() else {
return;
};
while let Some(old) = heading.next_sibling() {
section.remove(&old);
}
let width = gallery_width(ui);
let rows = gtk4::Box::new(gtk4::Orientation::Vertical, TILE_GAP);
for row in grid_rows(ui, photos, width) {
let row_box = gtk4::Box::new(gtk4::Orientation::Horizontal, TILE_GAP);
// A short last row stays left-aligned at the same tile size rather than
// stretching: an even grid is the whole point of the square layout.
row_box.set_halign(gtk4::Align::Start);
for tile in row {
row_box.append(&photo_tile(ui, tile));
}
rows.append(&row_box);
}
section.append(&rows);
schedule_thumbs(ui);
}
/// The width the grid is laid out to: the ListView's own width, less a couple of
/// px so a rounding error can't push a row into a horizontal overflow.
/// Falls back to a sane guess before the first allocation.
@@ -1238,27 +1300,35 @@ pub(crate) fn schedule_relayout(ui: &Rc<Ui>) {
*ui.gallery.relayout_source.borrow_mut() = Some(source);
}
/// Rebuild the tiles of the day sections currently on screen, at the current
/// width and zoom. Sections that are *not* realised need no work: they lay
/// themselves out against the current state when the ListView binds them.
/// Re-flow the timeline at the current width and zoom.
///
/// A different column count means different rows, so unlike the old
/// section-per-day model there is nothing to patch in place — the row model is
/// rebuilt. What that would cost the user is their scroll position, so the
/// topmost realised row's first photo is remembered and scrolled back to.
pub(crate) fn relayout_gallery(ui: &Rc<Ui>) {
let bound: Vec<(u32, gtk4::Box)> = ui
.gallery
.bound
.borrow()
.iter()
.map(|(pos, section)| (*pos, section.clone()))
.collect();
for (pos, section) in bound {
let Some(obj) = ui.gallery.groups.item(pos) else {
continue;
};
let Some(boxed) = obj.downcast_ref::<BoxedAnyObject>() else {
continue;
};
let photos = boxed.borrow::<PhotoGroup>().photos.clone();
fill_section(ui, &section, &photos);
}
let anchor: Option<String> = ui.gallery.bound.borrow().values().flatten().next().cloned();
repaint_gallery(ui);
let Some(anchor) = anchor else { return };
let Some(row) = row_of_photo(&ui.gallery.groups, &anchor) else {
return;
};
ui.gallery
.list
.scroll_to(row, gtk4::ListScrollFlags::empty(), None);
}
/// Which row of the rendered model holds `uid`, if any.
fn row_of_photo(store: &gio::ListStore, uid: &str) -> Option<u32> {
(0..store.n_items()).find(|i| {
store
.item(*i)
.and_downcast::<BoxedAnyObject>()
.is_some_and(|obj| match &*obj.borrow::<GalleryRow>() {
GalleryRow::Heading(_) => false,
GalleryRow::Tiles { photos, .. } => photos.iter().any(|p| p.uid == uid),
})
})
}
/// Step the tile size by `delta` px and re-flow, clamped to the zoom range.
@@ -1276,46 +1346,75 @@ pub(crate) fn set_gallery_tile(ui: &Rc<Ui>, tile: i32) {
schedule_relayout(ui);
}
/// Rebuild the day sections from the flat photo model. The timeline arrives
/// newest-first, so photos of the same day are already contiguous — one pass
/// splits them.
/// Rebuild the ListView's row model from the flat photo model: each day becomes
/// a heading row followed by its grid rows, laid out to the current width and
/// zoom.
///
/// The groups are diffed into the existing store rather than replacing it: a
/// "load more" only really changes the last day (the one the new page continues)
/// and appends after it, and clearing the store instead would scroll the user
/// back to the top of the timeline at the exact moment they asked for more.
/// The rows are diffed into the existing store rather than replacing it: a "load
/// more" only really changes the last day (the one the new page continues) and
/// appends after it, and clearing the store instead would scroll the user back
/// to the top of the timeline at the exact moment they asked for more.
pub(crate) fn repaint_gallery(ui: &Rc<Ui>) {
let groups = group_photos(&ui.gallery.model);
let rows = build_rows(ui);
let store = &ui.gallery.groups;
for (i, group) in groups.iter().enumerate() {
for (i, row) in rows.iter().enumerate() {
let i = i as u32;
let unchanged = store
.item(i)
.and_downcast::<BoxedAnyObject>()
.is_some_and(|old| {
let old = old.borrow::<PhotoGroup>();
old.heading == group.heading && old.photos.len() == group.photos.len()
});
.is_some_and(|old| row.same_as(&old.borrow::<GalleryRow>()));
if unchanged {
continue;
}
let boxed = BoxedAnyObject::new(PhotoGroup {
heading: group.heading.clone(),
photos: group.photos.clone(),
});
let boxed = BoxedAnyObject::new(clone_row(row));
if i < store.n_items() {
store.splice(i, 1, &[boxed]);
} else {
store.append(&boxed);
}
}
// Photos only ever get appended, so a shorter model means a fresh load.
if store.n_items() > groups.len() as u32 {
let len = groups.len() as u32;
if store.n_items() > rows.len() as u32 {
let len = rows.len() as u32;
store.splice(len, store.n_items() - len, &[] as &[BoxedAnyObject]);
}
update_gallery_subtitle(ui);
}
/// Flatten the loaded photos into the rows the ListView renders.
fn build_rows(ui: &Rc<Ui>) -> Vec<GalleryRow> {
let width = gallery_width(ui);
let mut rows = Vec::new();
for group in group_photos(&ui.gallery.model) {
rows.push(GalleryRow::Heading(group.heading));
let grid = grid_rows(ui, &group.photos, width);
let last_index = grid.len().saturating_sub(1);
for (index, tiles) in grid.into_iter().enumerate() {
let edge = tiles.first().map(|t| t.edge).unwrap_or(TILE_DEFAULT);
rows.push(GalleryRow::Tiles {
photos: tiles.into_iter().map(|t| t.photo).collect(),
edge,
last: index == last_index,
});
}
}
rows
}
fn clone_row(row: &GalleryRow) -> GalleryRow {
match row {
GalleryRow::Heading(heading) => GalleryRow::Heading(heading.clone()),
GalleryRow::Tiles { photos, edge, last } => GalleryRow::Tiles {
photos: photos.clone(),
edge: *edge,
last: *last,
},
}
}
/// "1,204 photos" under the page title.
fn update_gallery_subtitle(ui: &Rc<Ui>) {
let loaded = ui.gallery.model.n_items() as usize;
ui.gallery.subtitle.set_visible(loaded > 0);
// An album counts what the server says it holds, not how much of it has been
+15 -172
View File
@@ -1,26 +1,23 @@
//! `pdfs-prompt --dmenu`: the same search, presented in the user's own launcher.
//!
//! Everything here is blocking and GTK-free — the process exists for one
//! round-trip and then exits, so there is no main loop to keep off. It reuses
//! the GTK prompt's [`Hit`](crate::Hit) model and ranking, and its Drive
//! activation policy, so the two front ends cannot drift on what "open" means.
//! round-trip and then exits, so there is no main loop to keep off. Talking to
//! the daemon and opening a chosen file are [`crate::query`]'s, shared with the
//! fzf front end and built on the GTK prompt's own [`Hit`](crate::Hit) model
//! and activation policy; this module is only presentation.
//!
//! Launchers filter a static list; they cannot call back for a new one per
//! keystroke. So the interaction is a loop: the first menu shows pinned files
//! and accepts free text, and any text that matches no entry becomes the next
//! search. Escape (an empty selection) ends it.
use std::path::{Path, PathBuf};
//! search. Escape (an empty selection) ends it. `--fzf` ([`crate::fzf`]) is the
//! front end for users who want results to appear while they type instead.
use pdfs_core::config::AppDirs;
use pdfs_core::control::{
Request, Response, SearchFilters, SearchHit, SearchKind, SearchSource, send,
};
use pdfs_core::menu::{self, MenuChoice, MenuItem, PromptConfig};
use pdfs_core::opener::{self, OpenWith};
use pdfs_core::opener::OpenWith;
use crate::activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target};
use crate::{Hit, file_name, is_document, is_image, is_media, rank_hits};
use crate::Hit;
use crate::query;
/// Guard against a launcher that keeps handing back text we cannot satisfy —
/// without it a scripted (non-interactive) menu could spin forever.
@@ -48,10 +45,7 @@ pub(crate) fn run(options: Options) -> Result<(), String> {
.to_string()
})?;
let mountpoint = match request(&socket, Request::Status)? {
Response::Status { mountpoint, .. } => PathBuf::from(mountpoint),
_ => dirs.default_mountpoint(),
};
let mountpoint = query::mountpoint(&socket, &dirs);
let mut query = options
.query
@@ -59,8 +53,8 @@ pub(crate) fn run(options: Options) -> Result<(), String> {
.filter(|q| !q.is_empty());
for _ in 0..MAX_ROUNDS {
let hits = match &query {
Some(text) => search(&socket, text, prompt.resolved_menu_limit())?,
None => pins(&socket)?,
Some(text) => query::search(&socket, text, prompt.resolved_menu_limit())?,
None => query::pins(&socket)?,
};
let items: Vec<MenuItem> = if hits.is_empty() {
vec![MenuItem::new(placeholder(query.as_deref()), None)]
@@ -77,7 +71,7 @@ pub(crate) fn run(options: Options) -> Result<(), String> {
query = None;
continue;
};
return open(&socket, &mountpoint, &policy, hit, query.is_none());
return query::open(&socket, &mountpoint, &policy, hit, query.is_none());
}
// No entry matched what was typed: treat it as the next query.
MenuChoice::Custom(text) => query = Some(text),
@@ -118,57 +112,6 @@ fn placeholder(query: Option<&str>) -> String {
}
}
/// Pinned files — the same "you asked to keep these" set the GTK prompt opens
/// with, and the only listing the daemon offers without a query.
fn pins(socket: &Path) -> Result<Vec<Hit>, String> {
let Response::Pins { pins } = request(socket, Request::ListPins)? else {
return Ok(Vec::new());
};
Ok(pins
.into_iter()
.map(|pin| {
Hit::Drive(SearchHit {
name: file_name(&pin.path),
path: pin.path,
is_dir: pin.is_dir.unwrap_or(pin.recursive),
size: 0,
modified: 0,
pinned: true,
uid: pin.uid,
mounted_path: None,
score: 0,
})
})
.collect())
}
fn search(socket: &Path, query: &str, limit: usize) -> Result<Vec<Hit>, String> {
let reply = request(
socket,
Request::SearchV2 {
query: query.to_string(),
limit,
filters: SearchFilters {
sources: vec![SearchSource::Drive, SearchSource::Local],
kind: SearchKind::All,
},
},
)?;
let Response::SearchResultsV2 {
drive_hits,
local_hits,
..
} = reply
else {
return Ok(Vec::new());
};
let mut hits: Vec<Hit> = drive_hits.into_iter().map(Hit::Drive).collect();
hits.extend(local_hits.into_iter().map(Hit::Local));
rank_hits(&mut hits);
hits.truncate(limit);
Ok(hits)
}
/// One launcher line per hit. Labels must be unique: they are how the selection
/// is matched back, and two files of the same name in the same folder listing
/// would otherwise be indistinguishable.
@@ -181,101 +124,15 @@ fn label_all(hits: &[Hit]) -> Vec<MenuItem> {
label = format!("{} · {} ({suffix})", hit.name(), hit.location());
suffix += 1;
}
items.push(MenuItem::new(label, Some(icon_name(hit))));
items.push(MenuItem::new(label, Some(query::icon_name(hit))));
}
items
}
/// Icon theme name for a hit.
///
/// Coarse on purpose: `content_type_guess` would give an exact MIME name, but
/// only with file *contents* to sniff — a Drive hit is metadata, and passing it
/// an empty buffer answers `application-x-zerosize` for everything. The generic
/// names below exist in every icon theme.
fn icon_name(hit: &Hit) -> String {
if hit.is_dir() {
return "folder".to_string();
}
let name = hit.name();
if is_image(name) {
"image-x-generic"
} else if is_media(name) {
"video-x-generic"
} else if is_document(name) {
"text-x-generic"
} else {
"application-x-generic"
}
.to_string()
}
/// Open a chosen hit, mirroring the GTK prompt: local files are already on
/// disk, a Drive folder or streamable file goes through the mount, and anything
/// else is materialised by the daemon first.
fn open(
socket: &Path,
mountpoint: &Path,
policy: &OpenWith,
hit: &Hit,
from_pins: bool,
) -> Result<(), String> {
match hit {
Hit::Local(local) => {
opener::open(policy, Path::new(&local.path), local.is_dir);
Ok(())
}
Hit::Drive(drive) => {
// A folder has no materialised form, and a pin row's `is_dir` is pin
// policy rather than node kind, so both go to the mount unchecked.
let unconditional = from_pins
|| matches!(
drive_activation(&drive.name, drive.is_dir),
DriveActivation::Folder | DriveActivation::MountedMedia
);
if unconditional {
opener::open(
policy,
&mounted_or_relative(mountpoint, drive),
drive.is_dir,
);
return Ok(());
}
// Anything else the mount exposes opens there too, so an editor
// saves back into Drive instead of into a cache blob.
if let Some(path) = mounted_target(mountpoint, drive) {
opener::open(policy, &path, false);
return Ok(());
}
match request(
socket,
Request::OpenFile {
path: drive.path.clone(),
uid: Some(drive.uid.clone()),
},
)? {
Response::FilePath { path } => {
// The reply is a content-hash blob; the rules key off the
// Drive name, which is what the user actually opened.
opener::open_named(policy, Path::new(&path), &drive.name, false);
Ok(())
}
Response::Error { message, .. } => Err(format!("could not open: {message}")),
_ => Err("unexpected reply from the daemon".to_string()),
}
}
}
}
/// One control-socket round-trip, with the daemon-is-down case phrased for a
/// terminal rather than a status page.
fn request(socket: &Path, request: Request) -> Result<Response, String> {
send(socket, &request).map_err(|e| format!("cannot reach the Proton Drive daemon: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
use pdfs_core::control::LocalHit;
use pdfs_core::control::{LocalHit, SearchHit};
fn drive(name: &str, path: &str, is_dir: bool) -> Hit {
Hit::Drive(SearchHit {
@@ -331,18 +188,4 @@ mod tests {
assert!(placeholder(None).contains("type to search"));
assert!(placeholder(Some("md")).contains("md"));
}
#[test]
fn icons_are_theme_names_the_launcher_can_look_up() {
assert_eq!(icon_name(&drive("Photos", "Photos", true)), "folder");
assert_eq!(
icon_name(&drive("a.png", "a.png", false)),
"image-x-generic"
);
assert_eq!(icon_name(&drive("a.md", "a.md", false)), "text-x-generic");
assert_eq!(
icon_name(&drive("a.bin", "a.bin", false)),
"application-x-generic"
);
}
}
+372
View File
@@ -0,0 +1,372 @@
//! `pdfs-prompt --fzf`: launcher search that queries the daemon per keystroke.
//!
//! A dmenu-protocol launcher (fuzzel, rofi, wofi, …) is handed one static list
//! and filters it itself; it cannot ask for a new one as the user types, which
//! is why [`crate::dmenu`] has to make the search a two-step. `fzf` can:
//! `--bind change:reload(…)` re-runs a command on every keystroke. So this
//! front end re-executes *this binary* in `--feed` mode, and the daemon's own
//! ranking ([`pdfs_core::control::Request::SearchV2`]) decides the order —
//! `--disabled` turns fzf's matcher off so it does not re-sort what came back.
//!
//! Three roles, all the same binary:
//!
//! * **outer** — `--fzf` with no tty (a WM keybinding): spawn a terminal that
//! runs the inner role, and exit.
//! * **inner** — `--fzf` with a tty: run fzf, open what was chosen.
//! * **feed** — `--feed <TEXT>`: print one line per hit and exit. fzf spawns
//! this per keystroke and kills the one it superseded.
//!
//! A line is `display\tpayload`, shown through `--with-nth=1` so only the left
//! half is visible. The payload is the JSON [`Hit`], which is what makes the
//! selection unambiguous: unlike the dmenu path, identity does not have to be
//! recovered from the label, so [`crate::dmenu::label_all`]'s uniquifying
//! suffix has no counterpart here. JSON escapes tabs and newlines, so no file
//! name can split a line.
use std::io::{BufRead, BufReader, IsTerminal, Write};
use std::path::PathBuf;
use std::process::{Command, Stdio};
use pdfs_core::config::AppDirs;
use pdfs_core::menu::{self, PromptConfig};
use pdfs_core::opener::OpenWith;
use crate::query;
use crate::{Hit, SEARCH_DEBOUNCE};
/// Separates the visible half of a feed line from its payload.
const FIELD: char = '\t';
/// What the launcher prompt reads, matching the dmenu front end's wording.
const PROMPT: &str = "Search Drive › ";
pub(crate) struct Options {
/// Search for this immediately instead of opening on the pinned files.
pub query: Option<String>,
/// Set by the terminal we spawned: run fzf here even without a tty rather
/// than spawning a second terminal, which would recurse forever if the
/// terminal does not give its child one.
pub inner: bool,
}
/// Run the fzf flow. Returns an error only for conditions the user must act on
/// (no fzf, no terminal, no daemon); a plain cancel is `Ok`.
pub(crate) fn run(options: Options) -> Result<(), String> {
if options.inner || std::io::stdin().is_terminal() {
return interactive(options.query);
}
spawn_terminal(options.query)
}
/// Re-launch this binary's inner role inside a terminal.
///
/// Not waited on: a keybinding should return immediately, and the terminal
/// owns the rest of the interaction — including opening the chosen file, which
/// `opener` detaches anyway.
fn spawn_terminal(query: Option<String>) -> Result<(), String> {
let dirs = AppDirs::new().map_err(|e| format!("cannot resolve app dirs: {e}"))?;
let prompt: PromptConfig = dirs.load_config().resolved_prompt();
let terminal = menu::resolve_terminal(prompt.terminal.as_ref()).ok_or_else(|| {
"no terminal found — install foot/kitty/alacritty, or set prompt.terminal in config.json"
.to_string()
})?;
let argv = menu::with_command(&terminal, &inner_command(self_exe()?, query.as_deref()));
let (program, args) = argv
.split_first()
.ok_or_else(|| "no terminal command configured".to_string())?;
Command::new(program)
.args(args)
.spawn()
.map_err(|e| format!("cannot run terminal {program}: {e}"))?;
Ok(())
}
/// The argv the spawned terminal runs.
fn inner_command(exe: PathBuf, query: Option<&str>) -> Vec<String> {
let mut argv = vec![
exe.display().to_string(),
"--fzf".to_string(),
"--inner".to_string(),
];
if let Some(text) = query.filter(|text| !text.trim().is_empty()) {
argv.push("--query".to_string());
argv.push(text.to_string());
}
argv
}
/// Run fzf here and act on the choice.
fn interactive(query: Option<String>) -> Result<(), String> {
if !menu::on_path("fzf") {
return Err("fzf is not installed — install it, or use --dmenu".to_string());
}
let dirs = AppDirs::new().map_err(|e| format!("cannot resolve app dirs: {e}"))?;
let config = dirs.load_config();
let prompt: PromptConfig = config.resolved_prompt();
let policy: OpenWith = config.resolved_open_with();
let socket = dirs.control_socket();
let query = query
.map(|q| q.trim().to_string())
.filter(|q| !q.is_empty());
// fzf only fires `change` for a *keystroke*, so the first screen — pins, or
// the results for an initial --query — has to arrive on stdin.
let initial = hits(&socket, query.as_deref(), prompt.resolved_menu_limit());
let argv = fzf_argv(&self_exe()?, query.as_deref());
let (program, args) = argv.split_first().expect("fzf_argv is never empty");
let mut child = Command::new(program)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.map_err(|e| format!("cannot run fzf: {e}"))?;
// Written on a worker for the same reason the dmenu path does it: fzf reads
// stdin only once its UI is up, and a full pipe buffer would deadlock us.
let payload: String = initial.iter().map(encode_line).collect();
let mut stdin = child
.stdin
.take()
.ok_or_else(|| "fzf stdin unavailable".to_string())?;
let writer = std::thread::spawn(move || {
let _ = stdin.write_all(payload.as_bytes());
let _ = stdin.flush();
});
let mut selected = String::new();
if let Some(stdout) = child.stdout.take() {
let mut reader = BufReader::new(stdout);
let mut line = String::new();
while reader.read_line(&mut line).unwrap_or(0) > 0 {
if selected.is_empty() {
selected = line.trim_end_matches('\n').to_string();
}
line.clear();
}
}
let _ = child.wait();
let _ = writer.join();
// Escape, or Enter on the "cannot reach the daemon" row, which carries no
// payload — neither is an error.
let Some(hit) = decode_line(&selected) else {
return Ok(());
};
let mountpoint = query::mountpoint(&socket, &dirs);
// Only the no-query screen is the pin list, and only there does a row's
// `is_dir` mean pin policy rather than node kind.
query::open(&socket, &mountpoint, &policy, &hit, query.is_none())
}
/// `--feed`: one round-trip, printed for fzf to swallow.
///
/// Always succeeds. A reload command that exits non-zero leaves fzf showing an
/// empty list with no explanation, so a failure becomes a visible row instead.
pub(crate) fn feed(query: Option<&str>) {
let Ok(dirs) = AppDirs::new() else {
println!("(cannot resolve app dirs)");
return;
};
let limit = dirs.load_config().resolved_prompt().resolved_menu_limit();
let socket = dirs.control_socket();
let query = query.map(str::trim).filter(|text| !text.is_empty());
let stdout = std::io::stdout();
let mut out = stdout.lock();
for line in hits(&socket, query, limit).iter().map(encode_line) {
let _ = out.write_all(line.as_bytes());
}
}
/// The rows for one screen: pins when there is nothing typed, search results
/// otherwise — the same split the GTK prompt makes on an empty entry.
fn hits(socket: &std::path::Path, query: Option<&str>, limit: usize) -> Vec<Hit> {
let result = match query {
Some(text) => query::search(socket, text, limit),
None => query::pins(socket),
};
match result {
Ok(hits) => hits,
Err(message) => {
// Printed as a row rather than to stderr: fzf's alternate screen
// hides stderr, so this is the only place the user would see it.
println!("({message})");
Vec::new()
}
}
}
fn fzf_argv(exe: &std::path::Path, query: Option<&str>) -> Vec<String> {
let feed = format!("{} --feed {{q}}", shell_quote(&exe.display().to_string()));
// The sleep is the debounce: fzf kills a reload child that a later keystroke
// superseded, so a burst of typing costs one daemon round-trip, exactly as
// the GTK prompt's timeout does.
let debounce = SEARCH_DEBOUNCE.as_secs_f32();
let mut argv: Vec<String> = [
"fzf",
// fzf's own fuzzy matcher would re-order and re-filter what the daemon
// already ranked, and would hide rows it does not think match.
"--disabled",
"--no-sort",
"--no-multi",
"--layout=reverse",
"--info=inline",
"--delimiter=\t",
"--with-nth=1",
]
.iter()
.map(|arg| (*arg).to_string())
.collect();
argv.push(format!("--prompt={PROMPT}"));
// `reload:` rather than `reload(…)`: the unparenthesised form runs to the
// end of the argument, so an install path containing a bracket cannot
// truncate the command fzf parses out of it.
argv.push(format!("--bind=change:reload:sleep {debounce}; {feed}"));
if let Some(text) = query {
argv.push(format!("--query={text}"));
}
argv
}
/// `display\tpayload\n`. The display half is scrubbed of the delimiter and of
/// newlines; the payload is JSON, which escapes both.
fn encode_line(hit: &Hit) -> String {
let display =
format!("{} · {}", hit.name(), hit.location()).replace(['\n', '\r', FIELD], " ");
let payload = serde_json::to_string(hit).unwrap_or_default();
format!("{display}{FIELD}{payload}\n")
}
/// The hit a selected line stands for, or `None` for a line that carries no
/// payload — the error/empty row, or an empty selection (Escape).
fn decode_line(line: &str) -> Option<Hit> {
let (_, payload) = line.trim_end_matches('\n').rsplit_once(FIELD)?;
serde_json::from_str(payload).ok()
}
fn self_exe() -> Result<PathBuf, String> {
std::env::current_exe().map_err(|e| format!("cannot locate pdfs-prompt: {e}"))
}
/// POSIX single-quoting, for the one string that goes through fzf's `sh -c`.
fn shell_quote(text: &str) -> String {
format!("'{}'", text.replace('\'', r"'\''"))
}
#[cfg(test)]
mod tests {
use super::*;
use pdfs_core::control::{LocalHit, SearchHit};
fn drive(name: &str, path: &str) -> Hit {
Hit::Drive(SearchHit {
name: name.into(),
path: path.into(),
is_dir: false,
size: 7,
modified: 9,
pinned: false,
uid: "uid~1".into(),
mounted_path: None,
score: 42,
})
}
#[test]
fn a_line_round_trips_through_the_payload() {
let hit = drive("notes.md", "Docs/notes.md");
let line = encode_line(&hit);
assert!(line.ends_with('\n'));
let back = decode_line(&line).expect("payload decodes");
assert_eq!(back.name(), "notes.md");
assert_eq!(back.key(), hit.key());
assert_eq!(back.score(), 42);
}
#[test]
fn a_local_hit_round_trips_as_itself() {
let hit = Hit::Local(LocalHit {
name: "notes.md".into(),
path: "/home/me/Docs/notes.md".into(),
is_dir: false,
size: 1,
modified: 2,
score: 3,
});
let back = decode_line(&encode_line(&hit)).expect("payload decodes");
assert!(matches!(back, Hit::Local(_)));
assert_eq!(back.key(), hit.key());
}
#[test]
fn a_name_with_a_tab_or_newline_stays_one_line() {
let hit = drive("two\tparts\nfile.md", "Docs/two\tparts\nfile.md");
let line = encode_line(&hit);
assert_eq!(line.matches('\n').count(), 1, "{line:?}");
assert_eq!(line.matches(FIELD).count(), 1, "{line:?}");
assert_eq!(
decode_line(&line).expect("payload decodes").name(),
"two\tparts\nfile.md"
);
}
#[test]
fn a_row_without_a_payload_is_not_a_hit() {
assert!(decode_line("").is_none());
assert!(decode_line("(cannot reach the Proton Drive daemon)").is_none());
assert!(decode_line("bogus\tnot json").is_none());
}
#[test]
fn fzf_is_told_not_to_do_its_own_matching() {
let argv = fzf_argv(std::path::Path::new("/usr/bin/pdfs-prompt"), None);
assert!(argv.contains(&"--disabled".to_string()), "{argv:?}");
assert!(argv.contains(&"--no-sort".to_string()), "{argv:?}");
assert!(
argv.iter().any(|arg| arg.starts_with("--with-nth=")),
"the payload field must stay hidden: {argv:?}"
);
}
#[test]
fn every_keystroke_re_runs_the_feed_after_the_debounce() {
let argv = fzf_argv(std::path::Path::new("/usr/bin/pdfs-prompt"), Some("md"));
let bind = argv
.iter()
.find(|arg| arg.starts_with("--bind=change:"))
.expect("a change binding");
assert!(bind.contains("reload"), "{bind}");
assert!(bind.contains("--feed {q}"), "{bind}");
assert!(bind.contains(&format!("sleep {}", SEARCH_DEBOUNCE.as_secs_f32())));
assert!(argv.contains(&"--query=md".to_string()), "{argv:?}");
}
#[test]
fn a_path_with_a_quote_cannot_break_out_of_the_reload_command() {
let argv = fzf_argv(std::path::Path::new("/home/o'brien/my prompt"), None);
let bind = argv
.iter()
.find(|arg| arg.starts_with("--bind=change:"))
.expect("a change binding");
assert!(bind.contains(r"'/home/o'\''brien/my prompt'"), "{bind}");
// The unparenthesised reload form: a bracket in the path must not end
// the command as far as fzf's own parser is concerned.
assert!(bind.starts_with("--bind=change:reload:"), "{bind}");
}
#[test]
fn the_terminal_runs_the_inner_role_so_it_cannot_recurse() {
let argv = inner_command(PathBuf::from("/usr/bin/pdfs-prompt"), Some("md"));
assert_eq!(
argv,
vec!["/usr/bin/pdfs-prompt", "--fzf", "--inner", "--query", "md"]
);
assert_eq!(
inner_command(PathBuf::from("/usr/bin/pdfs-prompt"), Some(" ")),
vec!["/usr/bin/pdfs-prompt", "--fzf", "--inner"],
"a blank query is no query"
);
}
}
+43 -5
View File
@@ -24,6 +24,8 @@ use pdfs_core::opener::{self, OpenWith};
mod activation;
mod dmenu;
mod fzf;
mod query;
use activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target};
const APP_ID: &str = "io.narl.proton-drive-linux-prompt";
@@ -125,7 +127,7 @@ fn is_media(name: &str) -> bool {
/// One row in the unified result list. The two sections hold different payloads
/// — a Drive hit must be hydrated through the daemon before it can be opened, a
/// local file is already on disk — but they share one keyboard cursor.
#[derive(Clone)]
#[derive(Clone, serde::Serialize, serde::Deserialize)]
enum Hit {
Drive(SearchHit),
Local(LocalHit),
@@ -341,12 +343,20 @@ Options:
rofi, …) instead of the built-in GTK window. Also
settable permanently as \"prompt\": { \"mode\": \"dmenu\" }
in config.json.
--fzf Search in fzf, in a terminal, re-querying the daemon on
every keystroke — results appear as you type, unlike
--dmenu. Also settable as \"prompt\": { \"mode\": \"fzf\" }.
--gtk Force the built-in window, overriding that setting.
--menu <COMMAND> Launcher command line for --dmenu, e.g.
--menu 'fuzzel --dmenu --width 60'. Overrides
\"prompt\": { \"menu\": [...] }.
--query <TEXT> Search for TEXT immediately instead of opening on the
pinned-files list (--dmenu only).
pinned-files list (--dmenu and --fzf only).
--feed <TEXT> Print the hits for TEXT, one per line, and exit. This is
what --fzf re-runs on each keystroke; it is not meant to
be typed.
--inner Run fzf here rather than spawning a terminal. Set by the
terminal --fzf spawns; not meant to be typed.
-h, --help Show this help.
";
@@ -356,6 +366,12 @@ struct Args {
mode: Option<PromptMode>,
menu: Option<Vec<String>>,
query: Option<String>,
/// `--feed`: print hits for this text and exit. `Some(None)` is `--feed`
/// with an empty argument, which fzf sends for an empty input — a real
/// request for the pinned list, not an absent flag.
feed: Option<Option<String>>,
/// `--inner`: we are the process a spawned terminal is running.
inner: bool,
}
fn parse_args(argv: impl Iterator<Item = String>) -> Result<Args, String> {
@@ -363,12 +379,19 @@ fn parse_args(argv: impl Iterator<Item = String>) -> Result<Args, String> {
mode: None,
menu: None,
query: None,
feed: None,
inner: false,
};
let mut argv = argv.peekable();
while let Some(arg) = argv.next() {
match arg.as_str() {
"--dmenu" => args.mode = Some(PromptMode::Dmenu),
"--fzf" => args.mode = Some(PromptMode::Fzf),
"--gtk" => args.mode = Some(PromptMode::Gtk),
"--inner" => args.inner = true,
// fzf passes an empty argument for an empty input, so a missing
// value here is that case rather than a user error.
"--feed" => args.feed = Some(argv.next().filter(|text| !text.trim().is_empty())),
// A launcher command is one shell-ish string so it can live in a
// keybinding; splitting on whitespace is enough for flags, and a
// launcher argument needing spaces belongs in config.json.
@@ -416,16 +439,31 @@ fn main() -> glib::ExitCode {
}
};
// A feed is fzf's own reload child, not a front end: it must print hits and
// nothing else, so it is answered before the stored mode is even read.
if let Some(query) = args.feed {
fzf::feed(query.as_deref());
return glib::ExitCode::SUCCESS;
}
// The stored mode makes an existing keybinding switch front ends without
// being re-bound; an explicit flag still wins.
let stored = AppDirs::new()
.map(|dirs| dirs.load_config().resolved_prompt().resolved_mode())
.unwrap_or_default();
if args.mode.unwrap_or(stored) == PromptMode::Dmenu {
return match dmenu::run(dmenu::Options {
let launcher = match args.mode.unwrap_or(stored) {
PromptMode::Dmenu => Some(dmenu::run(dmenu::Options {
menu: args.menu,
query: args.query,
}) {
})),
PromptMode::Fzf => Some(fzf::run(fzf::Options {
query: args.query,
inner: args.inner,
})),
PromptMode::Gtk => None,
};
if let Some(result) = launcher {
return match result {
Ok(()) => glib::ExitCode::SUCCESS,
Err(message) => {
eprintln!("pdfs-prompt: {message}");
+194
View File
@@ -0,0 +1,194 @@
//! The daemon side of both launcher front ends: ask for hits, open a chosen one.
//!
//! `--dmenu` and `--fzf` differ only in how they present a list and collect a
//! choice. Everything between the control socket and the chosen file lives here
//! so the two cannot drift on what a result set is or what "open" means — the
//! same reason both already share [`Hit`] and [`rank_hits`] with the GTK prompt.
//!
//! Everything here is blocking and GTK-free: the callers are one-shot processes
//! with no main loop to keep free.
use std::path::{Path, PathBuf};
use pdfs_core::config::AppDirs;
use pdfs_core::control::{
Request, Response, SearchFilters, SearchHit, SearchKind, SearchSource, send,
};
use pdfs_core::opener::{self, OpenWith};
use crate::activation::{DriveActivation, drive_activation, mounted_or_relative, mounted_target};
use crate::{Hit, file_name, is_document, is_image, is_media, rank_hits};
/// Where the mount lives, for turning a Drive path into something an
/// application can open. Falls back to the configured default so a momentarily
/// unreachable daemon still produces a plausible path rather than an error.
pub(crate) fn mountpoint(socket: &Path, dirs: &AppDirs) -> PathBuf {
match request(socket, Request::Status) {
Ok(Response::Status { mountpoint, .. }) => PathBuf::from(mountpoint),
_ => dirs.default_mountpoint(),
}
}
/// Pinned files — the same "you asked to keep these" set the GTK prompt opens
/// with, and the only listing the daemon offers without a query.
pub(crate) fn pins(socket: &Path) -> Result<Vec<Hit>, String> {
let Response::Pins { pins } = request(socket, Request::ListPins)? else {
return Ok(Vec::new());
};
Ok(pins
.into_iter()
.map(|pin| {
Hit::Drive(SearchHit {
name: file_name(&pin.path),
path: pin.path,
is_dir: pin.is_dir.unwrap_or(pin.recursive),
size: 0,
modified: 0,
pinned: true,
uid: pin.uid,
mounted_path: None,
score: 0,
})
})
.collect())
}
pub(crate) fn search(socket: &Path, query: &str, limit: usize) -> Result<Vec<Hit>, String> {
let reply = request(
socket,
Request::SearchV2 {
query: query.to_string(),
limit,
filters: SearchFilters {
sources: vec![SearchSource::Drive, SearchSource::Local],
kind: SearchKind::All,
},
},
)?;
let Response::SearchResultsV2 {
drive_hits,
local_hits,
..
} = reply
else {
return Ok(Vec::new());
};
let mut hits: Vec<Hit> = drive_hits.into_iter().map(Hit::Drive).collect();
hits.extend(local_hits.into_iter().map(Hit::Local));
rank_hits(&mut hits);
hits.truncate(limit);
Ok(hits)
}
/// Icon theme name for a hit.
///
/// Coarse on purpose: `content_type_guess` would give an exact MIME name, but
/// only with file *contents* to sniff — a Drive hit is metadata, and passing it
/// an empty buffer answers `application-x-zerosize` for everything. The generic
/// names below exist in every icon theme.
pub(crate) fn icon_name(hit: &Hit) -> String {
if hit.is_dir() {
return "folder".to_string();
}
let name = hit.name();
if is_image(name) {
"image-x-generic"
} else if is_media(name) {
"video-x-generic"
} else if is_document(name) {
"text-x-generic"
} else {
"application-x-generic"
}
.to_string()
}
/// Open a chosen hit, mirroring the GTK prompt: local files are already on
/// disk, a Drive folder or streamable file goes through the mount, and anything
/// else is materialised by the daemon first.
pub(crate) fn open(
socket: &Path,
mountpoint: &Path,
policy: &OpenWith,
hit: &Hit,
from_pins: bool,
) -> Result<(), String> {
match hit {
Hit::Local(local) => {
opener::open(policy, Path::new(&local.path), local.is_dir);
Ok(())
}
Hit::Drive(drive) => {
// A folder has no materialised form, and a pin row's `is_dir` is pin
// policy rather than node kind, so both go to the mount unchecked.
let unconditional = from_pins
|| matches!(
drive_activation(&drive.name, drive.is_dir),
DriveActivation::Folder | DriveActivation::MountedMedia
);
if unconditional {
opener::open(
policy,
&mounted_or_relative(mountpoint, drive),
drive.is_dir,
);
return Ok(());
}
// Anything else the mount exposes opens there too, so an editor
// saves back into Drive instead of into a cache blob.
if let Some(path) = mounted_target(mountpoint, drive) {
opener::open(policy, &path, false);
return Ok(());
}
match request(
socket,
Request::OpenFile {
path: drive.path.clone(),
uid: Some(drive.uid.clone()),
},
)? {
Response::FilePath { path } => {
// The reply is a content-hash blob; the rules key off the
// Drive name, which is what the user actually opened.
opener::open_named(policy, Path::new(&path), &drive.name, false);
Ok(())
}
Response::Error { message, .. } => Err(format!("could not open: {message}")),
_ => Err("unexpected reply from the daemon".to_string()),
}
}
}
}
/// One control-socket round-trip, with the daemon-is-down case phrased for a
/// terminal rather than a status page.
pub(crate) fn request(socket: &Path, request: Request) -> Result<Response, String> {
send(socket, &request).map_err(|e| format!("cannot reach the Proton Drive daemon: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
fn drive(name: &str, is_dir: bool) -> Hit {
Hit::Drive(SearchHit {
name: name.into(),
path: name.into(),
is_dir,
size: 0,
modified: 0,
pinned: false,
uid: "uid".into(),
mounted_path: None,
score: 0,
})
}
#[test]
fn icons_are_theme_names_the_launcher_can_look_up() {
assert_eq!(icon_name(&drive("Photos", true)), "folder");
assert_eq!(icon_name(&drive("a.png", false)), "image-x-generic");
assert_eq!(icon_name(&drive("a.md", false)), "text-x-generic");
assert_eq!(icon_name(&drive("a.bin", false)), "application-x-generic");
}
}