mirror of
https://github.com/narrrl/proton-drive-linux.git
synced 2026-11-03 16:41:26 +01:00
feat(core,fuse,gui,cli): search scope and location column
Request::Search takes an optional folder scope (serde default, so older front-ends keep searching everywhere). The GUI offers a This folder toggle beside the search box and shows a Location column for hits; the CLI gains pdfs search --in. Pins report whether a pinned file is cached, so pin-derived search hits badge correctly.
This commit is contained in:
1 parent
0fbc6421ba
commit
29e5769dec
12 files changed
+204
-15
No files matched your search
@@ -242,6 +242,9 @@ enum Command {
|
||||
/// Max hits to return.
|
||||
#[arg(long, default_value_t = 50)]
|
||||
limit: usize,
|
||||
/// Only search below this folder (under the mountpoint).
|
||||
#[arg(long = "in", value_name = "FOLDER")]
|
||||
scope: Option<PathBuf>,
|
||||
},
|
||||
/// Rename a file or folder via the running daemon.
|
||||
Rename {
|
||||
@@ -730,7 +733,11 @@ fn main() -> Result<()> {
|
||||
Command::CancelImport => cmd_cancel_import(),
|
||||
Command::Album { uid, limit, offset } => cmd_album(uid, limit, offset),
|
||||
Command::OpenPhoto { uid } => cmd_open_photo(uid),
|
||||
Command::Search { query, limit } => cmd_search(query, limit),
|
||||
Command::Search {
|
||||
query,
|
||||
limit,
|
||||
scope,
|
||||
} => cmd_search(query, limit, scope),
|
||||
Command::Rename { path, new_name } => cmd_rename(path, new_name),
|
||||
Command::Move { path, new_parent } => cmd_move(path, new_parent),
|
||||
Command::Rm { path } => cmd_rm(path),
|
||||
@@ -2287,8 +2294,13 @@ fn cmd_open_photo(uid: String) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_search(query: String, limit: usize) -> Result<()> {
|
||||
match control_request(CtlRequest::Search { query, limit })? {
|
||||
fn cmd_search(query: String, limit: usize, scope: Option<PathBuf>) -> Result<()> {
|
||||
let scope = scope.map(|p| path_arg(&p)).transpose()?;
|
||||
match control_request(CtlRequest::Search {
|
||||
query,
|
||||
limit,
|
||||
scope,
|
||||
})? {
|
||||
CtlResponse::SearchResults { hits } if hits.is_empty() => println!("(no matches)"),
|
||||
CtlResponse::SearchResults { hits } => {
|
||||
for h in hits {
|
||||
|
||||
@@ -298,6 +298,10 @@ pub struct Pin {
|
||||
/// which is why it is defaulted.
|
||||
#[serde(default)]
|
||||
pub is_dir: Option<bool>,
|
||||
/// A pinned file whose content is on disk right now. The daemon fills it
|
||||
/// in when it answers `ListPins`; folders and older daemons say `false`.
|
||||
#[serde(default)]
|
||||
pub cached: bool,
|
||||
}
|
||||
|
||||
/// The legacy JSON pin registry, kept only to import a pre-P5 `pins.json` into
|
||||
@@ -1854,6 +1858,7 @@ impl ContentCache {
|
||||
path: row.path,
|
||||
recursive: row.recursive,
|
||||
is_dir: row.is_dir,
|
||||
cached: false,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -245,8 +245,15 @@ pub enum Request {
|
||||
uid: Option<String>,
|
||||
},
|
||||
/// Full-text search node names against the daemon's local metadata index.
|
||||
/// `limit` caps the number of hits returned. Replies with [`Response::SearchResults`].
|
||||
Search { query: String, limit: usize },
|
||||
/// `limit` caps the number of hits returned. `scope`, a mountpoint-relative
|
||||
/// folder, restricts hits to that subtree; older front-ends omit it and
|
||||
/// search everything. Replies with [`Response::SearchResults`].
|
||||
Search {
|
||||
query: String,
|
||||
limit: usize,
|
||||
#[serde(default)]
|
||||
scope: Option<String>,
|
||||
},
|
||||
/// Search the daemon's index of *local* (non-Drive) files on this machine.
|
||||
/// Independent of [`Request::Search`] so a front-end can fire both at once and
|
||||
/// render whichever lands first. Replies with [`Response::LocalResults`].
|
||||
@@ -2342,7 +2349,7 @@ mod tests {
|
||||
serde_json::from_str(r#"{"Search":{"query":"old","limit":20}}"#).unwrap();
|
||||
assert!(matches!(
|
||||
drive,
|
||||
Request::Search { query, limit } if query == "old" && limit == 20
|
||||
Request::Search { query, limit, scope: None } if query == "old" && limit == 20
|
||||
));
|
||||
|
||||
let local: Request =
|
||||
|
||||
@@ -355,6 +355,22 @@ impl Db {
|
||||
/// than [`TRIGRAM_MIN`] fall back to a `LIKE` scan since trigram indexes
|
||||
/// nothing below 3 chars.
|
||||
pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>> {
|
||||
self.search_in(query, limit, None)
|
||||
}
|
||||
|
||||
/// [`search`](Self::search) restricted to the subtree under the
|
||||
/// mountpoint-relative folder `scope`. `None` or an empty scope searches
|
||||
/// everything.
|
||||
pub fn search_in(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
scope: Option<&str>,
|
||||
) -> Result<Vec<SearchHit>> {
|
||||
let scope = scope.map(|s| s.trim_matches('/')).filter(|s| !s.is_empty());
|
||||
// Rows whose path is not persisted yet pass the SQL filter and are
|
||||
// checked once their path is walked below.
|
||||
let scope_pat = scope.map(|s| format!("{}/%", like_escape(s)));
|
||||
let query = query.trim();
|
||||
if query.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
@@ -365,9 +381,10 @@ impl Db {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT node_json, uid, path FROM nodes
|
||||
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL
|
||||
AND (?3 IS NULL OR path IS NULL OR path LIKE ?3 ESCAPE '\\')
|
||||
ORDER BY name LIMIT ?2",
|
||||
)?;
|
||||
collect_hits(stmt.query_map(params![pat, limit as i64], hit_row)?)?
|
||||
collect_hits(stmt.query_map(params![pat, limit as i64, scope_pat], hit_row)?)?
|
||||
} else {
|
||||
// Escape double quotes and quote each term, then combine with AND so
|
||||
// all terms must match but can appear in any order or position.
|
||||
@@ -380,9 +397,10 @@ impl Db {
|
||||
"SELECT n.node_json, n.uid, n.path
|
||||
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
|
||||
WHERE f.name MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
|
||||
AND (?3 IS NULL OR n.path IS NULL OR n.path LIKE ?3 ESCAPE '\\')
|
||||
ORDER BY f.rank LIMIT ?2",
|
||||
)?;
|
||||
collect_hits(stmt.query_map(params![phrase, limit as i64], hit_row)?)?
|
||||
collect_hits(stmt.query_map(params![phrase, limit as i64, scope_pat], hit_row)?)?
|
||||
};
|
||||
|
||||
let mut hits = Vec::with_capacity(rows.len());
|
||||
@@ -392,6 +410,13 @@ impl Db {
|
||||
Some(path) => path,
|
||||
None => walk_path_of(&conn, &uid)?,
|
||||
};
|
||||
if let Some(scope) = scope
|
||||
&& !path
|
||||
.strip_prefix(scope)
|
||||
.is_some_and(|rest| rest.starts_with('/'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
hits.push(SearchHit { node, path });
|
||||
}
|
||||
Ok(hits)
|
||||
|
||||
@@ -1083,6 +1083,40 @@ fn search_trigram_substring_and_path() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_in_scope_only_returns_hits_below_the_folder() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
db.upsert_node(&folder("root", None, "My Files")).unwrap();
|
||||
db.upsert_node(&folder("work", Some("root"), "Work"))
|
||||
.unwrap();
|
||||
db.upsert_node(&folder("sub", Some("work"), "Old")).unwrap();
|
||||
db.upsert_node(&folder("work2", Some("root"), "Work 2"))
|
||||
.unwrap();
|
||||
db.upsert_node(&file("f1", "work", "report.pdf", 1))
|
||||
.unwrap();
|
||||
db.upsert_node(&file("f2", "sub", "report-2019.pdf", 1))
|
||||
.unwrap();
|
||||
db.upsert_node(&file("f3", "work2", "report-copy.pdf", 1))
|
||||
.unwrap();
|
||||
db.upsert_node(&file("f4", "root", "report-top.pdf", 1))
|
||||
.unwrap();
|
||||
|
||||
let mut paths: Vec<String> = db
|
||||
.search_in("report", 10, Some("Work"))
|
||||
.unwrap()
|
||||
.into_iter()
|
||||
.map(|h| h.path)
|
||||
.collect();
|
||||
paths.sort();
|
||||
// A sibling whose name starts with the scope is not inside it.
|
||||
assert_eq!(paths, vec!["Work/Old/report-2019.pdf", "Work/report.pdf"]);
|
||||
// Short queries take the LIKE path and scope the same way.
|
||||
assert_eq!(db.search_in("re", 10, Some("Work/Old")).unwrap().len(), 1);
|
||||
// No scope, or the root, is everywhere.
|
||||
assert_eq!(db.search_in("report", 10, None).unwrap().len(), 4);
|
||||
assert_eq!(db.search_in("report", 10, Some("")).unwrap().len(), 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_excludes_trashed_and_respects_limit() {
|
||||
let db = Db::open_in_memory().unwrap();
|
||||
|
||||
@@ -284,7 +284,7 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
Ok(CtlRequest::ListPins) => CtlResponse::Pins {
|
||||
pins: core.cache.list_pins(),
|
||||
pins: core.list_pins(),
|
||||
},
|
||||
Ok(CtlRequest::ListDir { path }) => match route_to_mount(core, mountpoint, &path) {
|
||||
Ok((core, rel)) => match core.list_dir(&rel) {
|
||||
@@ -631,7 +631,11 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
|
||||
Err(e) => CtlResponse::error(e),
|
||||
}
|
||||
}
|
||||
Ok(CtlRequest::Search { query, limit }) => match core.search(&query, clamp_limit(limit)) {
|
||||
Ok(CtlRequest::Search {
|
||||
query,
|
||||
limit,
|
||||
scope,
|
||||
}) => match core.search(&query, clamp_limit(limit), scope.as_deref()) {
|
||||
Ok(hits) => CtlResponse::SearchResults { hits },
|
||||
Err(e) => CtlResponse::error(e),
|
||||
},
|
||||
|
||||
@@ -3857,10 +3857,25 @@ impl Core {
|
||||
/// Full-text search node names against the local SQLite index, mapping each
|
||||
/// DB hit to the wire [`SearchHit`] (resolving live pin state from the cache,
|
||||
/// which the DB doesn't track). Pure local lookup — never hits the network.
|
||||
fn search(&self, query: &str, limit: usize) -> CoreResult<Vec<SearchHit>> {
|
||||
/// Every pin, with `cached` saying whether a pinned file's content is on
|
||||
/// disk, so a front-end listing pins can badge them without a round-trip
|
||||
/// per file.
|
||||
fn list_pins(&self) -> Vec<pdfs_core::cache::Pin> {
|
||||
let mut pins = self.cache.list_pins();
|
||||
for pin in pins.iter_mut().filter(|pin| pin.is_dir == Some(false)) {
|
||||
if let Ok(Some(node)) = self.db.node_by_uid(&pin.uid) {
|
||||
pin.cached =
|
||||
self.cache
|
||||
.is_cached(&node.uid, node.modification_time, node_size(&node));
|
||||
}
|
||||
}
|
||||
pins
|
||||
}
|
||||
|
||||
fn search(&self, query: &str, limit: usize, scope: Option<&str>) -> CoreResult<Vec<SearchHit>> {
|
||||
let hits = self
|
||||
.db
|
||||
.search(query, limit)
|
||||
.search_in(query, limit, scope)
|
||||
.map_err(|e| CoreError::from_api(&e, "search"))?;
|
||||
let roots = self.search_roots();
|
||||
let pinned = self.pinned_set();
|
||||
|
||||
@@ -402,6 +402,7 @@ fn build_window(app: &adw::Application) {
|
||||
split: browser_widgets.split.clone(),
|
||||
path: RefCell::new(String::new()),
|
||||
search: browser_widgets.search.clone(),
|
||||
search_scope: browser_widgets.search_scope.clone(),
|
||||
actions: browser_widgets.actions.clone(),
|
||||
view_button: browser_widgets.view_button.clone(),
|
||||
view: Cell::new(FilesView::default()),
|
||||
@@ -416,6 +417,7 @@ fn build_window(app: &adw::Application) {
|
||||
search_source: RefCell::new(None),
|
||||
load_generation: Cell::new(0),
|
||||
views: browser_widgets.views.clone(),
|
||||
column_view: browser_widgets.column_view.clone(),
|
||||
bulk: browser_widgets.bulk.clone(),
|
||||
bulk_label: browser_widgets.bulk_label.clone(),
|
||||
bulk_trash: browser_widgets.bulk_trash.clone(),
|
||||
|
||||
@@ -27,6 +27,9 @@ pub(crate) struct BrowserState {
|
||||
pub(crate) path: RefCell<String>,
|
||||
/// Debounced full-text search box in the browser header.
|
||||
pub(crate) search: gtk4::SearchEntry,
|
||||
/// Beside the search box while a query is typed: on, hits are limited to
|
||||
/// the folder being shown; off, the whole Drive is searched.
|
||||
pub(crate) search_scope: gtk4::ToggleButton,
|
||||
/// The page's `files.*` actions: New and View menus, navigation, and the
|
||||
/// folder menu. The ones needing a daemon are disabled while the mount is
|
||||
/// down: without one they can only fail.
|
||||
@@ -55,6 +58,8 @@ pub(crate) struct BrowserState {
|
||||
pub(crate) load_generation: Cell<u64>,
|
||||
/// The grid/list view stack, read to find out which view is on screen.
|
||||
pub(crate) views: gtk4::Stack,
|
||||
/// The list view, whose Location column shows only for search hits.
|
||||
pub(crate) column_view: gtk4::ColumnView,
|
||||
/// The bulk-action bar, revealed once more than one entry is selected.
|
||||
pub(crate) bulk: gtk4::Revealer,
|
||||
pub(crate) bulk_label: gtk4::Label,
|
||||
@@ -122,6 +127,7 @@ pub(crate) struct BrowserWidgets {
|
||||
/// Sits in the status page; shown only when the mount service is down.
|
||||
pub(crate) retry: gtk4::Button,
|
||||
pub(crate) search: gtk4::SearchEntry,
|
||||
pub(crate) search_scope: gtk4::ToggleButton,
|
||||
pub(crate) actions: gio::SimpleActionGroup,
|
||||
pub(crate) view_button: adw::SplitButton,
|
||||
pub(crate) build_thumbnails: gtk4::Button,
|
||||
@@ -252,6 +258,15 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
|
||||
.valign(gtk4::Align::Center)
|
||||
.build();
|
||||
search.set_width_chars(18);
|
||||
// Hidden until there is a query to scope, and at the root, where "this
|
||||
// folder" is everywhere.
|
||||
let search_scope = gtk4::ToggleButton::builder()
|
||||
.icon_name("folder-symbolic")
|
||||
.tooltip_text("Search this folder only")
|
||||
.valign(gtk4::Align::Center)
|
||||
.visible(false)
|
||||
.build();
|
||||
search_scope.add_css_class("flat");
|
||||
|
||||
let refresh = refresh_button();
|
||||
|
||||
@@ -494,6 +509,7 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
|
||||
header.pack_end(&refresh);
|
||||
header.pack_end(&view_button);
|
||||
header.pack_end(&search);
|
||||
header.pack_end(&search_scope);
|
||||
let actions = gio::SimpleActionGroup::new();
|
||||
frame.insert_action_group("files", Some(&actions));
|
||||
|
||||
@@ -508,6 +524,7 @@ pub(crate) fn build_browser_page() -> (gtk4::Widget, BrowserWidgets) {
|
||||
status,
|
||||
retry,
|
||||
search,
|
||||
search_scope,
|
||||
actions,
|
||||
view_button,
|
||||
build_thumbnails,
|
||||
@@ -914,6 +931,10 @@ pub(crate) fn wire_browser(ui: &Rc<Ui>, grid: >k4::GridView, column_view: >k
|
||||
}
|
||||
}));
|
||||
column_view.append_column(&text_column("Modified", |e| format_modified(e.modified)));
|
||||
// Search hits come from anywhere in the Drive; this says where.
|
||||
let location = text_column(LOCATION_COLUMN, |e| hit_location(&e.path));
|
||||
location.set_visible(false);
|
||||
column_view.append_column(&location);
|
||||
|
||||
let ui_col = ui.clone();
|
||||
column_view.connect_activate(move |view, pos| {
|
||||
@@ -994,6 +1015,28 @@ pub(crate) fn name_column(ui: &Rc<Ui>) -> gtk4::ColumnViewColumn {
|
||||
column
|
||||
}
|
||||
|
||||
const LOCATION_COLUMN: &str = "Location";
|
||||
|
||||
/// The folder holding a search hit, as a mountpoint-relative path.
|
||||
fn hit_location(path: &str) -> String {
|
||||
match path.rsplit_once('/') {
|
||||
Some((parent, _)) => parent.to_string(),
|
||||
None => "My Files".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the Location column only while the list holds search hits.
|
||||
fn show_location_column(ui: &Rc<Ui>, visible: bool) {
|
||||
let columns = ui.browser.column_view.columns();
|
||||
for column in (0..columns.n_items())
|
||||
.filter_map(|i| columns.item(i).and_downcast::<gtk4::ColumnViewColumn>())
|
||||
{
|
||||
if column.title().as_deref() == Some(LOCATION_COLUMN) {
|
||||
column.set_visible(visible);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a trailing text column whose cell text is derived from each [`DirEntry`]
|
||||
/// by `render`.
|
||||
pub(crate) fn text_column(
|
||||
@@ -2590,6 +2633,7 @@ pub(crate) fn load_browser(ui: &Rc<Ui>) {
|
||||
let path = ui.browser.path.borrow().clone();
|
||||
repaint_crumb(ui, &path);
|
||||
sync_history_actions(ui);
|
||||
sync_search_scope(ui);
|
||||
ui.browser.summary.set_label("Loading…");
|
||||
|
||||
// Drop the previous folder's rows up front: a slow reply must not leave stale
|
||||
@@ -2683,6 +2727,7 @@ pub(crate) fn browser_unreachable(ui: &Rc<Ui>) {
|
||||
/// Repopulate the shared model — folders first, then case-insensitive by name —
|
||||
/// which refreshes both the grid and the column list.
|
||||
pub(crate) fn repaint_browser(ui: &Rc<Ui>, entries: &[DirEntry]) {
|
||||
show_location_column(ui, false);
|
||||
*ui.browser.listing.borrow_mut() = entries.to_vec();
|
||||
ui.browser.model.remove_all();
|
||||
ui.browser.summary.set_label(&listing_summary(
|
||||
@@ -2720,6 +2765,7 @@ pub(crate) fn wire_search(ui: &Rc<Ui>) {
|
||||
let ui_s = ui.clone();
|
||||
ui.browser.search.connect_search_changed(move |_| {
|
||||
sync_history_actions(&ui_s);
|
||||
sync_search_scope(&ui_s);
|
||||
// Replace any pending debounce so only the last keystroke's pause fires.
|
||||
if let Some(src) = ui_s.browser.search_source.borrow_mut().take() {
|
||||
src.remove();
|
||||
@@ -2736,6 +2782,31 @@ pub(crate) fn wire_search(ui: &Rc<Ui>) {
|
||||
});
|
||||
*ui_s.browser.search_source.borrow_mut() = Some(src);
|
||||
});
|
||||
let ui_s = ui.clone();
|
||||
ui.browser.search_scope.connect_toggled(move |scope| {
|
||||
scope.set_tooltip_text(Some(if scope.is_active() {
|
||||
"Search everywhere"
|
||||
} else {
|
||||
"Search this folder only"
|
||||
}));
|
||||
let query = ui_s.browser.search.text().trim().to_string();
|
||||
if !query.is_empty() {
|
||||
run_search(&ui_s, &query);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Offer the scope toggle only while there is a query and a folder to scope it to.
|
||||
pub(crate) fn sync_search_scope(ui: &Rc<Ui>) {
|
||||
let offered =
|
||||
!ui.browser.search.text().trim().is_empty() && !ui.browser.path.borrow().is_empty();
|
||||
ui.browser.search_scope.set_visible(offered);
|
||||
}
|
||||
|
||||
/// The folder a search is limited to, or `None` to search everywhere.
|
||||
fn search_scope(ui: &Rc<Ui>) -> Option<String> {
|
||||
let path = ui.browser.path.borrow();
|
||||
(ui.browser.search_scope.is_active() && !path.is_empty()).then(|| path.clone())
|
||||
}
|
||||
|
||||
/// Send a [`Request::Search`] to the daemon and render the hits in the browser
|
||||
@@ -2762,6 +2833,7 @@ pub(crate) fn run_search(ui: &Rc<Ui>, query: &str) {
|
||||
Request::Search {
|
||||
query: query.clone(),
|
||||
limit: SEARCH_LIMIT,
|
||||
scope: search_scope(ui),
|
||||
},
|
||||
);
|
||||
let ui = ui.clone();
|
||||
@@ -2793,6 +2865,7 @@ pub(crate) fn run_search(ui: &Rc<Ui>, query: &str) {
|
||||
/// each [`SearchHit`] to a path-carrying [`DirEntry`] the existing renderers and
|
||||
/// handlers already understand.
|
||||
pub(crate) fn repaint_search(ui: &Rc<Ui>, hits: &[SearchHit]) {
|
||||
show_location_column(ui, true);
|
||||
ui.browser.model.remove_all();
|
||||
let counts = listing_summary(
|
||||
hits.iter().filter(|hit| !hit.is_dir).count(),
|
||||
@@ -2861,7 +2934,8 @@ fn listing_summary(files: usize, folders: usize, file_bytes: u64) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
listing_summary, move_target_allowed, show_thumbnail_build_progress, sort_entries,
|
||||
hit_location, listing_summary, move_target_allowed, show_thumbnail_build_progress,
|
||||
sort_entries,
|
||||
};
|
||||
use pdfs_core::config::{FileSort, FilesView};
|
||||
use pdfs_core::control::{DirEntry, ThumbnailBuildStatus};
|
||||
@@ -2887,6 +2961,12 @@ mod tests {
|
||||
entries.iter().map(|e| e.name.as_str()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_search_hit_location_is_its_parent_folder() {
|
||||
assert_eq!(hit_location("Work/Old/a.txt"), "Work/Old");
|
||||
assert_eq!(hit_location("a.txt"), "My Files");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listing_sorts_by_the_chosen_key_with_folders_first() {
|
||||
let mut entries = vec![
|
||||
|
||||
@@ -908,7 +908,7 @@ impl Ui {
|
||||
size: 0,
|
||||
modified: 0,
|
||||
pinned: true,
|
||||
cached: false,
|
||||
cached: pin.cached,
|
||||
uid: pin.uid,
|
||||
mounted_path: None,
|
||||
score: 0,
|
||||
|
||||
@@ -45,7 +45,7 @@ pub(crate) fn pins(socket: &Path) -> Result<Vec<Hit>, String> {
|
||||
size: 0,
|
||||
modified: 0,
|
||||
pinned: true,
|
||||
cached: false,
|
||||
cached: pin.cached,
|
||||
uid: pin.uid,
|
||||
mounted_path: None,
|
||||
score: 0,
|
||||
|
||||
@@ -78,6 +78,11 @@ Schema: **31** (unchanged).
|
||||
instead of covering them. It has Info, Offline, Sharing and Versions sections: who has access or
|
||||
whether there is a public link, how many versions there are, and Share…, Copy Link and
|
||||
Versions… buttons.
|
||||
- **Search in this folder.** Inside a folder, a folder button beside the search box limits the search
|
||||
to that folder and everything below it. `pdfs search --in <folder>` does the same from the CLI.
|
||||
In the list view a Location column shows which folder each hit is in. Pinned files listed by the
|
||||
search prompt and `pdfs-query` show whether they are on disk, instead of always showing them as
|
||||
not downloaded.
|
||||
- **US spelling throughout.** The app, the CLI and the docs say "Favorite" and "color", matching the
|
||||
`pdfs favorite` command.
|
||||
|
||||
|
||||
Reference in new issue
Block a user