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

+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();