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:
nvrl committed 2026-09-23 03:12:18 +02:00
1 parent 0fbc6421ba
commit 29e5769dec
12 files changed
+204 -15

No files matched your search

+15 -3
View File
@@ -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 {
+5
View File
@@ -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()
}
+10 -3
View File
@@ -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 =
+27 -2
View File
@@ -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)
+34
View File
@@ -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();
+6 -2
View File
@@ -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),
},
+17 -2
View File
@@ -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();
+2
View File
@@ -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(),
+81 -1
View File
@@ -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: &gtk4::GridView, column_view: &gtk
}
}));
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![
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+5
View File
@@ -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.