diff --git a/crates/pdfs-cli/src/main.rs b/crates/pdfs-cli/src/main.rs index 04b1ccc..a2fe2f5 100644 --- a/crates/pdfs-cli/src/main.rs +++ b/crates/pdfs-cli/src/main.rs @@ -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, }, /// 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) -> 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 { diff --git a/crates/pdfs-core/src/cache.rs b/crates/pdfs-core/src/cache.rs index c9a6ed0..859964b 100644 --- a/crates/pdfs-core/src/cache.rs +++ b/crates/pdfs-core/src/cache.rs @@ -298,6 +298,10 @@ pub struct Pin { /// which is why it is defaulted. #[serde(default)] pub is_dir: Option, + /// 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() } diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index 900f069..04ca1df 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -245,8 +245,15 @@ pub enum Request { uid: Option, }, /// 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, + }, /// 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 = diff --git a/crates/pdfs-core/src/db/nodes.rs b/crates/pdfs-core/src/db/nodes.rs index a669c0f..4c2842b 100644 --- a/crates/pdfs-core/src/db/nodes.rs +++ b/crates/pdfs-core/src/db/nodes.rs @@ -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> { + 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> { + 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) diff --git a/crates/pdfs-core/src/db/tests.rs b/crates/pdfs-core/src/db/tests.rs index 1ffc8c0..8c77f4b 100644 --- a/crates/pdfs-core/src/db/tests.rs +++ b/crates/pdfs-core/src/db/tests.rs @@ -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 = 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(); diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index 1529426..809e3bf 100644 --- a/crates/pdfs-fuse/src/control.rs +++ b/crates/pdfs-fuse/src/control.rs @@ -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), }, diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index ab8e03f..1f03118 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -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> { + /// 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 { + 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> { 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(); diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index 6edb6dd..56100b8 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -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(), diff --git a/crates/pdfs-gui/src/app/pages/browser.rs b/crates/pdfs-gui/src/app/pages/browser.rs index 8054552..4e3b022 100644 --- a/crates/pdfs-gui/src/app/pages/browser.rs +++ b/crates/pdfs-gui/src/app/pages/browser.rs @@ -27,6 +27,9 @@ pub(crate) struct BrowserState { pub(crate) path: RefCell, /// 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, /// 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, 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) -> 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, visible: bool) { + let columns = ui.browser.column_view.columns(); + for column in (0..columns.n_items()) + .filter_map(|i| columns.item(i).and_downcast::()) + { + 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) { 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) { /// 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, 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) { 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_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) { + 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) -> Option { + 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, 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, query: &str) { /// each [`SearchHit`] to a path-carrying [`DirEntry`] the existing renderers and /// handlers already understand. pub(crate) fn repaint_search(ui: &Rc, 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![ diff --git a/crates/pdfs-gui/src/prompt.rs b/crates/pdfs-gui/src/prompt.rs index 8ee345e..34cd581 100644 --- a/crates/pdfs-gui/src/prompt.rs +++ b/crates/pdfs-gui/src/prompt.rs @@ -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, diff --git a/crates/pdfs-gui/src/query.rs b/crates/pdfs-gui/src/query.rs index 04c89e5..8ee0d45 100644 --- a/crates/pdfs-gui/src/query.rs +++ b/crates/pdfs-gui/src/query.rs @@ -45,7 +45,7 @@ pub(crate) fn pins(socket: &Path) -> Result, String> { size: 0, modified: 0, pinned: true, - cached: false, + cached: pin.cached, uid: pin.uid, mounted_path: None, score: 0, diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3aaf24c..e719afd 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -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 ` 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.