mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
fix(GALLERY) fixed takeout date conversion + repair command
This commit is contained in:
1 parent
16ad6548c8
commit
99ad3838e1
23 files changed
+2028
-325
No files matched your search
@@ -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"] }
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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: >k4::ListView, scroll: >k4::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(§ion));
|
||||
// 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, §ion, &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: >k4::ListView, scroll: >k4::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: >k4::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, §ion, &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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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}");
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user