From 39ab4aae483e9c9fc844227caed49b98471a18fe Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Wed, 22 Jul 2026 09:50:42 +0200 Subject: [PATCH] prompt refactor --- crates/pdfs-core/src/control.rs | 321 +++++++++++++++++++++ crates/pdfs-core/src/db/local.rs | 53 ++++ crates/pdfs-core/src/db/migrations.rs | 39 ++- crates/pdfs-core/src/db/nodes.rs | 119 +++++++- crates/pdfs-core/src/db/tests.rs | 89 ++++++ crates/pdfs-core/src/lib.rs | 1 + crates/pdfs-core/src/search.rs | 214 ++++++++++++++ crates/pdfs-fuse/src/control.rs | 12 + crates/pdfs-fuse/src/lib.rs | 155 ++++++++++- crates/pdfs-gui/src/activation.rs | 89 ++++++ crates/pdfs-gui/src/app/main.rs | 2 + crates/pdfs-gui/src/app/pages/browser.rs | 29 +- crates/pdfs-gui/src/prompt.rs | 337 +++++++++++++++++------ scripts/fuse-acceptance.sh | 60 ++++ 14 files changed, 1409 insertions(+), 111 deletions(-) create mode 100644 crates/pdfs-core/src/search.rs create mode 100644 crates/pdfs-gui/src/activation.rs diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index ed750f0..226ae4b 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -101,6 +101,19 @@ pub enum Request { /// Independent of [`Request::Search`] so a front-end can fire both at once and /// render whichever lands first. Replies with [`Response::LocalResults`]. SearchLocal { query: String, limit: usize }, + /// Search both the Drive metadata index and the local-file index in one + /// round-trip. This is the preferred prompt API; the two legacy search + /// requests remain available for older clients. `limit` applies to each + /// source independently. `filters` is applied before limiting, so selecting + /// (for example) images cannot hide valid matches below an unfiltered top + /// `limit`. Older clients omit it and therefore search both sources and all + /// kinds. Replies with [`Response::SearchResultsV2`]. + SearchV2 { + query: String, + limit: usize, + #[serde(default)] + filters: SearchFilters, + }, /// Rename a file or folder. `path` is mountpoint-relative; `new_name` is a /// single path component (no separators). Replies with [`Response::Ok`]. Rename { path: String, new_name: String }, @@ -665,6 +678,10 @@ pub struct SearchHit { pub pinned: bool, /// Node uid in `volume~link` form. pub uid: String, + /// Relevance assigned by the daemon. Higher scores sort first. Legacy + /// daemons omit it, in which case clients retain the daemon's result order. + #[serde(default)] + pub score: i64, } /// One hit in a [`Request::SearchLocal`] result: a file on this machine, outside @@ -679,6 +696,107 @@ pub struct LocalHit { pub size: u64, /// Modification time, epoch seconds. pub modified: i64, + /// Relevance assigned by the daemon. Higher scores sort first. Legacy + /// daemons omit it, in which case clients retain the daemon's result order. + #[serde(default)] + pub score: i64, +} + +/// A searchable corpus in [`Request::SearchV2`]. Kept explicit on the wire so +/// future sources can be added without multiplying request variants. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SearchSource { + Drive, + Local, +} + +/// Coarse result kind used by the prompt's facets. Classification belongs in +/// core/the daemon and must happen before the per-source limit is applied. +#[derive(Serialize, Deserialize, Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SearchKind { + #[default] + All, + Folders, + Documents, + Images, + Media, +} + +impl SearchKind { + /// Classify a result before the caller applies its final limit. This stays + /// beside the wire enum so every front end and both search corpora agree. + pub fn accepts(self, name: &str, is_dir: bool) -> bool { + if self == Self::All { + return true; + } + if self == Self::Folders { + return is_dir; + } + if is_dir { + return false; + } + let extension = std::path::Path::new(name) + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default() + .to_ascii_lowercase(); + match self { + Self::Documents => matches!( + extension.as_str(), + "pdf" + | "doc" + | "docx" + | "odt" + | "rtf" + | "txt" + | "md" + | "xls" + | "xlsx" + | "ods" + | "csv" + | "ppt" + | "pptx" + | "odp" + | "epub" + ), + Self::Images => matches!( + extension.as_str(), + "png" | "jpg" | "jpeg" | "webp" | "gif" | "bmp" | "svg" | "avif" | "heic" | "tiff" + ), + Self::Media => { + PhotoKind::classify(Some(name), None) == PhotoKind::Video + || matches!( + extension.as_str(), + "mp3" | "flac" | "wav" | "ogg" | "opus" | "m4a" | "aac" + ) + } + Self::All | Self::Folders => unreachable!(), + } + } +} + +/// Optional constraints for a unified search. The default deliberately means +/// the original SearchV2 behaviour, preserving old JSON requests. +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] +pub struct SearchFilters { + /// Sources to query. An explicit empty list searches neither source. + #[serde(default = "default_search_sources")] + pub sources: Vec, + #[serde(default)] + pub kind: SearchKind, +} + +impl Default for SearchFilters { + fn default() -> Self { + Self { + sources: default_search_sources(), + kind: SearchKind::All, + } + } +} + +fn default_search_sources() -> Vec { + vec![SearchSource::Drive, SearchSource::Local] } /// What kind of media a timeline entry is, so the Photos page can split into @@ -947,6 +1065,14 @@ pub enum Response { /// is true while a scan of the machine is still running, so a front-end can /// say "still indexing" instead of "no matches" on a cold first launch. LocalResults { hits: Vec, indexing: bool }, + /// Drive and local-file search results returned together (reply to + /// [`Request::SearchV2`]). `local_indexing` distinguishes a genuinely empty + /// local result set from one observed while the initial scan is running. + SearchResultsV2 { + drive_hits: Vec, + local_hits: Vec, + local_indexing: bool, + }, /// A snapshot of what the daemon is working on (reply to /// [`Request::GetQueueStatus`]): `items` are byte-moving transfers, `jobs` /// the longer non-transfer work around them (scans, folder skeletons, the @@ -1104,6 +1230,58 @@ mod tests { assert_eq!(PhotoKind::classify(Some("x.jpg"), Some("video/mp4")), Photo); } + /// Keep every extension accepted by the shared classifier covered. Drive + /// activation uses this classification before it knows whether opening a + /// file should stream through FUSE or materialise a complete cache entry; + /// silently dropping one of these formats would therefore regress large + /// videos back to the expensive whole-file path. + #[test] + fn photo_kind_recognises_every_supported_video_extension() { + for ext in VIDEO_EXTS { + let lower = format!("movie.{ext}"); + let upper = format!("MOVIE.{}", ext.to_ascii_uppercase()); + assert_eq!( + PhotoKind::classify(Some(&lower), None), + PhotoKind::Video, + "lower-case .{ext}" + ); + assert_eq!( + PhotoKind::classify(Some(&upper), None), + PhotoKind::Video, + "upper-case .{ext}" + ); + } + } + + #[test] + fn photo_kind_uses_video_mime_only_when_extension_is_not_authoritative() { + use PhotoKind::*; + + assert_eq!( + PhotoKind::classify(Some("extensionless"), Some("video/mp4; codecs=avc1")), + Video + ); + assert_eq!( + PhotoKind::classify(Some("clip.unknown"), Some("video/x-matroska")), + Video + ); + // Audio is not a video and must not accidentally take the video + // streaming policy merely because both are time-based media. + assert_eq!( + PhotoKind::classify(Some("recording"), Some("audio/flac")), + Photo + ); + // A known still/raw suffix remains authoritative over server metadata. + assert_eq!( + PhotoKind::classify(Some("poster.AVIF"), Some("video/mp4")), + Photo + ); + assert_eq!( + PhotoKind::classify(Some("negative.NEF"), Some("video/mp4")), + Raw + ); + } + #[test] fn photo_kind_i64_round_trips() { for k in [PhotoKind::Photo, PhotoKind::Video, PhotoKind::Raw] { @@ -1167,6 +1345,149 @@ mod tests { } } + #[test] + fn unified_search_request_and_response_roundtrip() { + let request = Request::SearchV2 { + query: "quarterly report".into(), + limit: 37, + filters: SearchFilters::default(), + }; + let line = serde_json::to_string(&request).unwrap(); + assert_eq!( + line, + r#"{"SearchV2":{"query":"quarterly report","limit":37,"filters":{"sources":["Drive","Local"],"kind":"All"}}}"# + ); + let decoded: Request = serde_json::from_str(&line).unwrap(); + assert_eq!(line, serde_json::to_string(&decoded).unwrap()); + + let response = Response::SearchResultsV2 { + drive_hits: vec![SearchHit { + name: "report.pdf".into(), + path: "Work/report.pdf".into(), + is_dir: false, + size: 12, + modified: 123, + pinned: true, + uid: "vol~link".into(), + score: 985, + }], + local_hits: vec![LocalHit { + name: "notes.txt".into(), + path: "/home/me/notes.txt".into(), + is_dir: false, + size: 7, + modified: 456, + score: 720, + }], + local_indexing: true, + }; + let line = serde_json::to_string(&response).unwrap(); + assert!(!line.contains('\n')); + let decoded: Response = serde_json::from_str(&line).unwrap(); + match decoded { + Response::SearchResultsV2 { + drive_hits, + local_hits, + local_indexing, + } => { + assert_eq!(drive_hits.len(), 1); + assert_eq!(drive_hits[0].uid, "vol~link"); + assert_eq!(drive_hits[0].score, 985); + assert_eq!(local_hits.len(), 1); + assert_eq!(local_hits[0].path, "/home/me/notes.txt"); + assert_eq!(local_hits[0].score, 720); + assert!(local_indexing); + } + other => panic!("expected unified search results, got {other:?}"), + } + } + + #[test] + fn search_v2_legacy_wire_form_gets_compatible_defaults() { + let decoded: Request = + serde_json::from_str(r#"{"SearchV2":{"query":"old client","limit":20}}"#).unwrap(); + match decoded { + Request::SearchV2 { + query, + limit, + filters, + } => { + assert_eq!(query, "old client"); + assert_eq!(limit, 20); + assert_eq!(filters, SearchFilters::default()); + } + other => panic!("expected SearchV2, got {other:?}"), + } + + // New daemons add scores, but old-daemon replies remain readable and + // signal that their existing order should be retained with score 0. + let decoded: Response = serde_json::from_str( + r#"{"SearchResultsV2":{"drive_hits":[{"name":"a","path":"a","is_dir":false,"size":1,"modified":2,"pinned":false,"uid":"v~a"}],"local_hits":[{"name":"b","path":"/b","is_dir":false,"size":3,"modified":4}],"local_indexing":false}}"#, + ) + .unwrap(); + match decoded { + Response::SearchResultsV2 { + drive_hits, + local_hits, + .. + } => { + assert_eq!(drive_hits[0].score, 0); + assert_eq!(local_hits[0].score, 0); + } + other => panic!("expected SearchResultsV2, got {other:?}"), + } + } + + #[test] + fn search_v2_filters_roundtrip() { + let request = Request::SearchV2 { + query: "holiday".into(), + limit: 50, + filters: SearchFilters { + sources: vec![SearchSource::Drive], + kind: SearchKind::Images, + }, + }; + let line = serde_json::to_string(&request).unwrap(); + let decoded: Request = serde_json::from_str(&line).unwrap(); + assert!(matches!( + decoded, + Request::SearchV2 { + filters: SearchFilters { sources, kind: SearchKind::Images }, + .. + } if sources == vec![SearchSource::Drive] + )); + } + + #[test] + fn search_kind_classifies_before_results_are_limited() { + assert!(SearchKind::Folders.accepts("movie.mp4", true)); + assert!(!SearchKind::Folders.accepts("movie.mp4", false)); + assert!(SearchKind::Documents.accepts("REPORT.PDF", false)); + assert!(SearchKind::Images.accepts("scan.HEIC", false)); + assert!(SearchKind::Media.accepts("film.mkv", false)); + assert!(SearchKind::Media.accepts("recording.flac", false)); + assert!(!SearchKind::Media.accepts("poster.jpg", false)); + assert!(SearchKind::All.accepts("anything", true)); + } + + #[test] + fn legacy_search_wire_forms_still_decode() { + let drive: Request = + serde_json::from_str(r#"{"Search":{"query":"old","limit":20}}"#).unwrap(); + assert!(matches!( + drive, + Request::Search { query, limit } if query == "old" && limit == 20 + )); + + let local: Request = + serde_json::from_str(r#"{"SearchLocal":{"query":"old","limit":10}}"#).unwrap(); + assert!(matches!( + local, + Request::SearchLocal { query, limit } if query == "old" && limit == 10 + )); + } + /// The trash requests carry uids rather than paths; they must survive the same /// line-delimited round-trip, since a mangled uid would restore or destroy the /// wrong node. diff --git a/crates/pdfs-core/src/db/local.rs b/crates/pdfs-core/src/db/local.rs index 5382f79..5ae40b6 100644 --- a/crates/pdfs-core/src/db/local.rs +++ b/crates/pdfs-core/src/db/local.rs @@ -7,6 +7,7 @@ use super::Db; use crate::Result; use crate::localindex::LocalEntry; +use super::nodes::candidate_trigrams; use super::utils::{TRIGRAM_MIN, like_escape}; /// One hit from [`Db::search_local`]: an indexed file on the machine itself, not @@ -147,6 +148,58 @@ impl Db { } Ok(out) } + + /// Bounded candidates for the fuzzy scorer, including matches in the full + /// local path. ORed trigrams admit misspellings while the FTS index prevents + /// this from becoming an unbounded filesystem-table scan. + pub fn search_local_candidates(&self, query: &str, limit: usize) -> Result> { + let query = query.trim(); + if query.is_empty() || limit == 0 { + return Ok(Vec::new()); + } + let conn = self.conn.lock(); + let terms = candidate_trigrams(query); + let lane_limit = limit.div_ceil(2); + let mut rows: Vec = if terms.is_empty() { + let pat = format!("%{}%", like_escape(query)); + let mut stmt = conn.prepare( + "SELECT path, name, is_dir, size, mtime FROM local_files + WHERE name LIKE ?1 ESCAPE '\\' OR path LIKE ?1 ESCAPE '\\' + ORDER BY mtime DESC LIMIT ?2", + )?; + stmt.query_map(params![pat, limit as i64], local_hit)? + .collect::>()? + } else { + let mut stmt = conn.prepare( + "SELECT f.path, f.name, f.is_dir, f.size, f.mtime + FROM local_fts x JOIN local_files f ON f.id = x.rowid + WHERE local_fts MATCH ?1 + ORDER BY x.rank LIMIT ?2", + )?; + stmt.query_map(params![terms.join(" OR "), lane_limit as i64], local_hit)? + .collect::>()? + }; + if !terms.is_empty() + && let Some(first) = query.chars().next() + { + let prefix = format!("{}%", like_escape(&first.to_string())); + let mut stmt = conn.prepare( + "SELECT path, name, is_dir, size, mtime FROM local_files + WHERE name COLLATE NOCASE LIKE ?1 ESCAPE '\\' + ORDER BY name COLLATE NOCASE LIMIT ?2", + )?; + let fallback = stmt + .query_map(params![prefix, lane_limit as i64], local_hit)? + .collect::, _>>()?; + for hit in fallback { + if !rows.iter().any(|existing| existing.path == hit.path) { + rows.push(hit); + } + } + rows.truncate(limit); + } + Ok(rows) + } } /// Row → [`LocalFileHit`]. Every local-search query selects the same diff --git a/crates/pdfs-core/src/db/migrations.rs b/crates/pdfs-core/src/db/migrations.rs index 1b4dc18..d9a1e37 100644 --- a/crates/pdfs-core/src/db/migrations.rs +++ b/crates/pdfs-core/src/db/migrations.rs @@ -9,7 +9,7 @@ use super::Db; use crate::Result; /// Current schema version. Bump on every forward migration added below. -pub(super) const SCHEMA_VERSION: i64 = 15; +pub(super) const SCHEMA_VERSION: i64 = 16; impl Db { pub(super) fn migrate(&self) -> Result<()> { @@ -81,6 +81,9 @@ impl Db { if current < 15 { tx.execute_batch(MIGRATION_V15)?; } + if current < 16 { + tx.execute_batch(MIGRATION_V16)?; + } tx.execute( "INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1) ON CONFLICT(key) DO UPDATE SET value = excluded.value", @@ -369,3 +372,37 @@ CREATE VIRTUAL TABLE nodes_fts USING fts5( INSERT INTO nodes_fts (rowid, name) SELECT rowid, name FROM nodes WHERE trashed = 0; "; + +/// Schema v16: fuzzy search needs a bounded candidate set that includes parent +/// paths. Keeping the path in the trigram indexes lets SQLite narrow both name +/// and path candidates without walking either base table for every keystroke. +/// Node paths are initially backfilled below and subsequently maintained by the +/// node write path (including descendants after a folder move/rename). +const MIGRATION_V16: &str = " +DROP TABLE IF EXISTS nodes_fts; +CREATE VIRTUAL TABLE nodes_fts USING fts5( + name, path, tokenize='trigram' +); +CREATE INDEX IF NOT EXISTS idx_nodes_name_nocase ON nodes(name COLLATE NOCASE); +INSERT INTO nodes_fts (rowid, name, path) +WITH RECURSIVE paths(rowid, uid, path) AS ( + SELECT n.rowid, n.uid, '' FROM nodes n + WHERE n.parent_uid IS NULL + OR NOT EXISTS (SELECT 1 FROM nodes p WHERE p.uid = n.parent_uid) + UNION ALL + SELECT n.rowid, n.uid, + CASE WHEN paths.path = '' THEN n.name ELSE paths.path || '/' || n.name END + FROM nodes n JOIN paths ON n.parent_uid = paths.uid +) +SELECT n.rowid, n.name, paths.path + FROM nodes n JOIN paths ON paths.rowid = n.rowid + WHERE n.trashed = 0; + +DROP TABLE IF EXISTS local_fts; +CREATE VIRTUAL TABLE local_fts USING fts5( + name, path, content='local_files', content_rowid='id', tokenize='trigram' +); +CREATE INDEX IF NOT EXISTS idx_local_files_name_nocase + ON local_files(name COLLATE NOCASE); +INSERT INTO local_fts(local_fts) VALUES('rebuild'); +"; diff --git a/crates/pdfs-core/src/db/nodes.rs b/crates/pdfs-core/src/db/nodes.rs index eeb0496..c048368 100644 --- a/crates/pdfs-core/src/db/nodes.rs +++ b/crates/pdfs-core/src/db/nodes.rs @@ -81,12 +81,42 @@ impl Db { params![uid], |row| row.get(0), )?; - tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![rowid])?; - if !node.trashed { - tx.execute( - "INSERT INTO nodes_fts (rowid, name) VALUES (?1, ?2)", - params![rowid, node.name], + // A folder rename/move changes every descendant's searchable path. + // Refresh the affected subtree, while retaining efficient rowid + // deletes for the overwhelmingly common one-file case. + let mut affected = vec![rowid]; + if node.is_folder() { + let mut stmt = tx.prepare( + "WITH RECURSIVE descendants(rowid, uid) AS ( + SELECT rowid, uid FROM nodes WHERE parent_uid = ?1 + UNION ALL + SELECT n.rowid, n.uid FROM nodes n + JOIN descendants d ON n.parent_uid = d.uid + ) SELECT rowid FROM descendants", )?; + for descendant in stmt.query_map(params![uid], |row| row.get(0))? { + affected.push(descendant?); + } + } + for affected_rowid in affected { + tx.execute( + "DELETE FROM nodes_fts WHERE rowid = ?1", + params![affected_rowid], + )?; + let indexed: Option<(String, String)> = tx + .query_row( + "SELECT name, uid FROM nodes WHERE rowid = ?1 AND trashed = 0", + params![affected_rowid], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + if let Some((name, indexed_uid)) = indexed { + let path = path_of(&tx, &indexed_uid)?; + tx.execute( + "INSERT INTO nodes_fts (rowid, name, path) VALUES (?1, ?2, ?3)", + params![affected_rowid, name, path], + )?; + } } } tx.commit()?; @@ -175,6 +205,68 @@ impl Db { Ok(hits) } + /// Return a bounded, deliberately broad candidate pool for fuzzy ranking. + /// Unlike [`search`](Self::search), trigram terms are ORed across both the + /// basename and parent path, so a typo can still share enough trigrams to + /// enter the pool. Final relevance ordering belongs to the caller. + pub fn search_candidates(&self, query: &str, limit: usize) -> Result> { + let query = query.trim(); + if query.is_empty() || limit == 0 { + return Ok(Vec::new()); + } + let conn = self.conn.lock(); + let terms = candidate_trigrams(query); + let lane_limit = limit.div_ceil(2); + let mut rows: Vec<(String, String)> = if terms.is_empty() { + let pat = format!("%{}%", like_escape(query)); + let mut stmt = conn.prepare( + "SELECT node_json, uid FROM nodes + WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL + ORDER BY name LIMIT ?2", + )?; + collect_pairs(stmt.query_map(params![pat, limit as i64], pair)?)? + } else { + let expression = terms.join(" OR "); + let mut stmt = conn.prepare( + "SELECT n.node_json, n.uid + FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid + WHERE nodes_fts MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL + ORDER BY f.rank LIMIT ?2", + )?; + collect_pairs(stmt.query_map(params![expression, lane_limit as i64], pair)?)? + }; + // A short substitution can destroy every trigram (`vedio` vs `video`). + // Feed the scorer an additional indexed same-initial lane so it can + // recover those candidates without scanning the full node table. + if !terms.is_empty() + && let Some(first) = query.chars().next() + { + let prefix = format!("{}%", like_escape(&first.to_string())); + let mut stmt = conn.prepare( + "SELECT node_json, uid FROM nodes + WHERE name COLLATE NOCASE LIKE ?1 ESCAPE '\\' + AND trashed = 0 AND node_json IS NOT NULL + ORDER BY name COLLATE NOCASE LIMIT ?2", + )?; + let fallback = + collect_pairs(stmt.query_map(params![prefix, lane_limit as i64], pair)?)?; + for row in fallback { + if !rows.iter().any(|(_, uid)| uid == &row.1) { + rows.push(row); + } + } + rows.truncate(limit); + } + rows.into_iter() + .map(|(json, uid)| { + Ok(SearchHit { + node: serde_json::from_str(&json)?, + path: path_of(&conn, &uid)?, + }) + }) + .collect() + } + /// Mark (or unmark) a folder's child listing as complete. A listed folder /// rehydrates its `children` map on mount even when empty; an unlisted one /// re-enumerates from the remote on next access. @@ -261,6 +353,23 @@ impl Db { // in sync on every store/read/evict, so it is authoritative for eviction. } +/// Unique quoted character trigrams suitable for an FTS5 OR expression. +pub(super) fn candidate_trigrams(query: &str) -> Vec { + let chars: Vec = query.to_lowercase().chars().collect(); + let mut terms = Vec::new(); + for window in chars.windows(TRIGRAM_MIN) { + if window.iter().all(|c| c.is_whitespace()) { + continue; + } + let raw: String = window.iter().collect(); + let quoted = format!("\"{}\"", raw.replace('"', "\"\"")); + if !terms.contains("ed) { + terms.push(quoted); + } + } + terms +} + /// Effective plaintext size of a node for the indexed `size` column: the /// claimed size when known, else the on-storage size; folders are 0. pub(super) fn node_size(node: &Node) -> i64 { diff --git a/crates/pdfs-core/src/db/tests.rs b/crates/pdfs-core/src/db/tests.rs index 1dc55a9..83f34c7 100644 --- a/crates/pdfs-core/src/db/tests.rs +++ b/crates/pdfs-core/src/db/tests.rs @@ -542,6 +542,95 @@ fn search_short_query_like_fallback() { assert_eq!(hits[0].node.name, "ab.txt"); } +#[test] +fn fuzzy_candidates_include_typo_and_parent_path_matches() { + let db = Db::open_in_memory().unwrap(); + db.upsert_node(&folder("root", None, "My Files")).unwrap(); + db.upsert_node(&folder("projects", Some("root"), "Projects")) + .unwrap(); + db.upsert_node(&file("report", "projects", "quarterly-report.pdf", 1)) + .unwrap(); + db.upsert_node(&file("video", "projects", "video.mp4", 1)) + .unwrap(); + + let typo = db.search_candidates("quartrly", 20).unwrap(); + assert!( + typo.iter() + .any(|hit| hit.node.name == "quarterly-report.pdf") + ); + assert!( + db.search_candidates("vedio", 20) + .unwrap() + .iter() + .any(|hit| hit.node.name == "video.mp4") + ); + + let path = db.search_candidates("projects", 20).unwrap(); + assert!( + path.iter() + .any(|hit| hit.node.name == "quarterly-report.pdf") + ); +} + +#[test] +fn renaming_parent_refreshes_descendant_search_paths() { + let db = Db::open_in_memory().unwrap(); + db.upsert_node(&folder("root", None, "My Files")).unwrap(); + db.upsert_node(&folder("folder", Some("root"), "zzbeforezz")) + .unwrap(); + db.upsert_node(&file("child", "folder", "plain.txt", 1)) + .unwrap(); + assert!( + db.search_candidates("zzbeforezz", 20) + .unwrap() + .iter() + .any(|h| h.node.name == "plain.txt") + ); + + db.upsert_node(&folder("folder", Some("root"), "yyafteryy")) + .unwrap(); + assert!( + !db.search_candidates("zzbeforezz", 20) + .unwrap() + .iter() + .any(|h| h.node.name == "plain.txt") + ); + assert!( + db.search_candidates("yyafteryy", 20) + .unwrap() + .iter() + .any(|h| h.node.name == "plain.txt") + ); +} + +#[test] +fn local_fuzzy_candidates_include_typo_and_parent_path() { + let db = Db::open_in_memory().unwrap(); + let generation = db.local_begin_scan().unwrap(); + db.local_upsert_batch( + generation, + &[ + local( + "/home/u/Projects/quarterly-report.pdf", + "quarterly-report.pdf", + false, + ), + local("/home/u/Videos/video.mp4", "video.mp4", false), + ], + ) + .unwrap(); + db.local_finish_scan(generation, 1).unwrap(); + + assert_eq!(db.search_local_candidates("quartrly", 20).unwrap().len(), 1); + assert_eq!(db.search_local_candidates("projects", 20).unwrap().len(), 1); + assert!( + db.search_local_candidates("vedio", 20) + .unwrap() + .iter() + .any(|hit| hit.name == "video.mp4") + ); +} + #[test] fn search_drops_fts_row_on_delete_and_trash() { let db = Db::open_in_memory().unwrap(); diff --git a/crates/pdfs-core/src/lib.rs b/crates/pdfs-core/src/lib.rs index 5ee34a5..4ef0eed 100644 --- a/crates/pdfs-core/src/lib.rs +++ b/crates/pdfs-core/src/lib.rs @@ -10,6 +10,7 @@ pub mod db; pub mod error; pub mod localindex; pub mod profile; +pub mod search; pub mod service; pub mod shell; pub mod syncignore; diff --git a/crates/pdfs-core/src/search.rs b/crates/pdfs-core/src/search.rs new file mode 100644 index 0000000..fe8e161 --- /dev/null +++ b/crates/pdfs-core/src/search.rs @@ -0,0 +1,214 @@ +//! Shared, deterministic search-result relevance scoring. +//! +//! The scorer deliberately operates on a small candidate set rather than +//! replacing an index. Callers should use SQLite (or another index) to find +//! candidates, then use this module to put local and remote results on the +//! same relevance scale. + +/// Scores a candidate against a user query. +/// +/// Every query term must match either `basename` or `parent_path`. Basename +/// matches are weighted twice as strongly as path matches. The returned score +/// is stable and only meaningful when compared with scores produced by this +/// function. +#[must_use] +pub fn relevance_score(query: &str, basename: &str, parent_path: &str) -> Option { + let query = normalize(query); + if query.is_empty() { + return Some(0); + } + + let name = normalize(basename); + let path = normalize(parent_path); + let query_terms: Vec<_> = query.split_whitespace().collect(); + let name_words: Vec<_> = name.split_whitespace().collect(); + let path_words: Vec<_> = path.split_whitespace().collect(); + + let mut total = 0_i64; + for term in query_terms { + let name_score = field_term_score(term, &name, &name_words).map(|score| score * 2); + let path_score = field_term_score(term, &path, &path_words); + total += name_score.into_iter().chain(path_score).max()?; + } + + // Whole-field bonuses make ordering intuitive without changing whether a + // candidate matches. They dominate small differences between term scores. + if name == query { + total += 20_000; + } else if name.starts_with(&query) { + total += 10_000; + } else if name.contains(&query) { + total += 4_000; + } + + Some(total) +} + +fn normalize(value: &str) -> String { + let mut normalized = String::with_capacity(value.len()); + let mut last_was_space = true; + + for character in value.chars().flat_map(char::to_lowercase) { + if character.is_alphanumeric() { + normalized.push(character); + last_was_space = false; + } else if !last_was_space { + normalized.push(' '); + last_was_space = true; + } + } + + if last_was_space { + normalized.pop(); + } + normalized +} + +fn field_term_score(term: &str, field: &str, words: &[&str]) -> Option { + if field == term { + return Some(10_000); + } + if field.starts_with(term) { + return Some(8_500); + } + if words.contains(&term) { + return Some(8_000); + } + if words.iter().any(|word| word.starts_with(term)) { + return Some(7_000); + } + if field.contains(term) { + return Some(6_000); + } + + // Subsequence matching supports useful abbreviations such as "qtrrpt". + let subsequence = std::iter::once(field) + .chain(words.iter().copied()) + .filter_map(|word| subsequence_gap(term, word)) + .min(); + if let Some(gap) = subsequence { + return Some(4_500 - i64::try_from(gap.min(500)).unwrap_or(500)); + } + + // Typo matching is deliberately restricted to substantial terms to avoid + // noisy matches for one- and two-letter searches. + if term.chars().count() >= 4 + && words + .iter() + .any(|word| bounded_damerau_levenshtein(term, word, typo_limit(term))) + { + return Some(3_500); + } + + None +} + +fn subsequence_gap(needle: &str, haystack: &str) -> Option { + let needle_length = needle.chars().count(); + let mut needle = needle.chars(); + let mut wanted = needle.next()?; + let mut first = None; + + for (index, character) in haystack.chars().enumerate() { + if character == wanted { + first.get_or_insert(index); + match needle.next() { + Some(next) => wanted = next, + None => return Some(index + 1 - first.unwrap_or(index) - needle_length), + } + } + } + None +} + +fn typo_limit(term: &str) -> usize { + usize::from(term.chars().count() >= 5) + 1 +} + +/// A bounded optimal-string-alignment distance check. This treats a single +/// adjacent transposition (for example, `vidoe`) as one edit. +fn bounded_damerau_levenshtein(left: &str, right: &str, limit: usize) -> bool { + let left: Vec<_> = left.chars().collect(); + let right: Vec<_> = right.chars().collect(); + if left.len().abs_diff(right.len()) > limit || left.len().max(right.len()) > 64 { + return false; + } + + let mut distances = vec![vec![0_usize; right.len() + 1]; left.len() + 1]; + for (index, row) in distances.iter_mut().enumerate() { + row[0] = index; + } + for (index, distance) in distances[0].iter_mut().enumerate() { + *distance = index; + } + + for i in 1..=left.len() { + for j in 1..=right.len() { + let cost = usize::from(left[i - 1] != right[j - 1]); + distances[i][j] = (distances[i - 1][j] + 1) + .min(distances[i][j - 1] + 1) + .min(distances[i - 1][j - 1] + cost); + if i > 1 && j > 1 && left[i - 1] == right[j - 2] && left[i - 2] == right[j - 1] { + distances[i][j] = distances[i][j].min(distances[i - 2][j - 2] + 1); + } + } + } + + distances[left.len()][right.len()] <= limit +} + +#[cfg(test)] +mod tests { + use super::relevance_score; + + #[test] + fn exact_name_beats_prefix_and_substring() { + let exact = relevance_score("report", "report", "work").unwrap(); + let prefix = relevance_score("report", "report final", "work").unwrap(); + let substring = relevance_score("report", "annual report", "work").unwrap(); + assert!(exact > prefix); + assert!(prefix > substring); + } + + #[test] + fn basename_is_weighted_above_parent_path() { + let name = relevance_score("budget", "budget notes", "archive").unwrap(); + let path = relevance_score("budget", "notes", "work/budget").unwrap(); + assert!(name > path); + } + + #[test] + fn matches_typos_and_adjacent_transpositions() { + assert!(relevance_score("vedio", "video.mp4", "media").is_some()); + assert!(relevance_score("vidoe", "video.mp4", "media").is_some()); + assert!(relevance_score("presentaton", "presentation.pdf", "work").is_some()); + } + + #[test] + fn separators_form_searchable_word_boundaries() { + assert!(relevance_score("final draft", "Final_Draft-v2.pdf", "work").is_some()); + assert!(relevance_score("quarter report", "quarterly-report.pdf", "work").is_some()); + } + + #[test] + fn unicode_case_folding_preserves_accented_matches() { + assert!(relevance_score("CAFÉ", "Café Photos", "Trips").is_some()); + assert!(relevance_score("ångström", "ÅNGSTRÖM notes", "Research").is_some()); + } + + #[test] + fn matches_terms_across_name_and_path() { + assert!(relevance_score("tax invoice", "invoice-1042.pdf", "archive/tax/2025").is_some()); + } + + #[test] + fn ordered_subsequence_supports_abbreviations() { + assert!(relevance_score("qtrrpt", "quarter-report.pdf", "finance").is_some()); + } + + #[test] + fn rejects_unrelated_candidates_and_short_typos() { + assert_eq!(relevance_score("video", "holiday.jpg", "photos"), None); + assert_eq!(relevance_score("xy", "xz.txt", "misc"), None); + } +} diff --git a/crates/pdfs-fuse/src/control.rs b/crates/pdfs-fuse/src/control.rs index e923db5..e58cfa6 100644 --- a/crates/pdfs-fuse/src/control.rs +++ b/crates/pdfs-fuse/src/control.rs @@ -241,6 +241,18 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U }, Err(e) => CtlResponse::error(e), }, + Ok(CtlRequest::SearchV2 { + query, + limit, + filters, + }) => match core.search_v2(&query, limit, &filters) { + Ok((drive_hits, local_hits)) => CtlResponse::SearchResultsV2 { + drive_hits, + local_hits, + local_indexing: core.indexing.load(Ordering::Relaxed), + }, + Err(e) => CtlResponse::error(e), + }, Ok(CtlRequest::Rename { path, new_name }) => match rel_to_mount(mountpoint, &path) { Ok(rel) => match core.rename(&rel, &new_name) { Ok(name) => { diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 86bd628..68175ba 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -35,6 +35,7 @@ use parking_lot::{Condvar, Mutex}; use std::collections::{HashMap, HashSet, VecDeque}; use std::ffi::OsStr; use std::fs::File; +use std::io::Write; use std::os::unix::fs::FileExt; use std::os::unix::net::UnixListener; use std::path::{Component, Path, PathBuf}; @@ -54,13 +55,15 @@ use pdfs_core::cache::{BLOCK_SIZE, Baseline, ContentCache, StagedWrite}; use pdfs_core::config::AppDirs; use pdfs_core::control::{ ActivityEntry, ActivityKind, DirEntry, ErrorKind, LocalHit, PhotoKind, PublicLinkInfo, - SearchHit, SyncFolderInfo, SyncPhase, SyncProgress, TransferDirection, + SearchFilters, SearchHit, SearchSource, SyncFolderInfo, SyncPhase, SyncProgress, + TransferDirection, }; use pdfs_core::db::{ Db, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp, StoredNode, StoredSyncFolder, StoredTrash, }; use pdfs_core::localindex; +use pdfs_core::search::relevance_score; use pdfs_core::{CoreError, CoreResult}; use proton_drive_rs::proton_sdk::api::ResponseCode; use proton_drive_rs::proton_sdk::error::ProtonError; @@ -1524,6 +1527,27 @@ impl Core { Ok(out.into_inner()) } + /// Download a whole file directly into `out`, without retaining its + /// plaintext in memory. This is the disk-backed twin of + /// [`Core::download_file_tracked`], and preserves the same transfer + /// accounting while allowing callers that only need an on-disk file to + /// keep memory use independent of file size. + fn download_file_tracked_to( + &self, + uid: &NodeUid, + name: &str, + total: u64, + out: W, + ) -> std::result::Result { + let guard = self + .transfers + .begin(name, uid.to_string(), TransferDirection::Download, total); + let mut out = CountingWriter::new(out, &guard); + self.rt + .block_on(self.client.download_file_to(uid, &mut out))?; + Ok(out.into_inner()) + } + /// Like [`download_file_tracked`] for a photo, streaming through the photos /// client's [`download_photo_to`]. /// @@ -1778,6 +1802,7 @@ impl Core { modified: h.node.modification_time, pinned: self.cache.is_pinned(&h.node.uid), uid: h.node.uid.to_string(), + score: 0, }) .collect()) } @@ -1798,10 +1823,95 @@ impl Core { is_dir: h.is_dir, size: h.size.max(0) as u64, modified: h.mtime, + score: 0, }) .collect()) } + /// Search both prompt sources with identical query and per-source limits. + /// Keeping the composition in the daemon core gives every control surface + /// the same semantics while preserving the legacy source-specific methods. + fn search_v2( + &self, + query: &str, + limit: usize, + filters: &SearchFilters, + ) -> CoreResult<(Vec, Vec)> { + if query.trim().is_empty() || limit == 0 { + return Ok((Vec::new(), Vec::new())); + } + // Candidate generation is intentionally broader than the final result + // cap: filtering and fuzzy rejection happen below, so limiting at the + // SQLite boundary to `limit` would recreate the old empty-filter bug. + let candidate_limit = limit.saturating_mul(10).clamp(100, 1_000); + let mut drive_hits = if filters.sources.contains(&SearchSource::Drive) { + self.db + .search_candidates(query, candidate_limit) + .map_err(|e| CoreError::from_api(&e, "search candidates"))? + .into_iter() + .filter(|hit| filters.kind.accepts(&hit.node.name, hit.node.is_folder())) + .filter_map(|hit| { + let parent = Path::new(&hit.path) + .parent() + .map_or_else(String::new, |path| path.display().to_string()); + let pinned = self.cache.is_pinned(&hit.node.uid); + let score = relevance_score(query, &hit.node.name, &parent)? + + if pinned { 250 } else { 0 }; + Some(SearchHit { + name: hit.node.name.clone(), + path: hit.path, + is_dir: hit.node.is_folder(), + size: node_size(&hit.node), + modified: hit.node.modification_time, + pinned, + uid: hit.node.uid.to_string(), + score, + }) + }) + .collect() + } else { + Vec::new() + }; + let mut local_hits = if filters.sources.contains(&SearchSource::Local) { + self.db + .search_local_candidates(query, candidate_limit) + .map_err(|e| CoreError::from_api(&e, "local search candidates"))? + .into_iter() + .filter(|hit| filters.kind.accepts(&hit.name, hit.is_dir)) + .filter_map(|hit| { + let parent = Path::new(&hit.path) + .parent() + .map_or_else(String::new, |path| path.display().to_string()); + Some(LocalHit { + score: relevance_score(query, &hit.name, &parent)?, + name: hit.name, + path: hit.path, + is_dir: hit.is_dir, + size: hit.size.max(0) as u64, + modified: hit.mtime, + }) + }) + .collect() + } else { + Vec::new() + }; + drive_hits.sort_by(|a, b| { + b.score + .cmp(&a.score) + .then_with(|| a.name.cmp(&b.name)) + .then_with(|| a.path.cmp(&b.path)) + }); + local_hits.sort_by(|a, b| { + b.score + .cmp(&a.score) + .then_with(|| a.name.cmp(&b.name)) + .then_with(|| a.path.cmp(&b.path)) + }); + drive_hits.truncate(limit); + local_hits.truncate(limit); + Ok((drive_hits, local_hits)) + } + /// A page of the photos timeline (newest first), sliced out of the DB. /// `Ok(None)` when the account has no photos volume. /// @@ -1847,12 +1957,43 @@ impl Core { if let Some(p) = self.cache.cached_content_path(&uid, mtime, size) { return Ok(p); } - let bytes = self - .download_file_tracked(&uid, &name, size) - .map_err(|e| CoreError::from_api(&e, "download"))?; - self.cache - .store(&uid, mtime, size, &bytes) - .map_err(|e| CoreError::internal(format!("cache store: {e}")))?; + + // Keep the temporary file beside the final cache blob. `store_file` + // can then adopt it with a hard link and atomic rename, while the SDK + // streams decrypted blocks to disk instead of building a file-sized + // `Vec`. A unique name also keeps concurrent opens of the same node + // from sharing a partially written staging file. + static OPEN_TEMP_SEQ: AtomicU64 = AtomicU64::new(0); + let seq = OPEN_TEMP_SEQ.fetch_add(1, Ordering::Relaxed); + let tmp = self.cache.content_path(&uid).with_extension(format!( + "open-{}-{}-{seq}.tmp", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0) + )); + let file = File::options() + .write(true) + .create_new(true) + .open(&tmp) + .map_err(|e| CoreError::internal(format!("create download temp: {e}")))?; + let file = match self.download_file_tracked_to(&uid, &name, size, file) { + Ok(file) => file, + Err(e) => { + let _ = std::fs::remove_file(&tmp); + return Err(CoreError::from_api(&e, "download")); + } + }; + if let Err(e) = file.sync_all() { + let _ = std::fs::remove_file(&tmp); + return Err(CoreError::internal(format!("sync download temp: {e}"))); + } + if let Err(e) = self.cache.store_file(&uid, mtime, size, &tmp) { + let _ = std::fs::remove_file(&tmp); + return Err(CoreError::internal(format!("cache store: {e}"))); + } + let _ = std::fs::remove_file(&tmp); Ok(self.cache.content_path(&uid)) } diff --git a/crates/pdfs-gui/src/activation.rs b/crates/pdfs-gui/src/activation.rs new file mode 100644 index 0000000..611ff7c --- /dev/null +++ b/crates/pdfs-gui/src/activation.rs @@ -0,0 +1,89 @@ +//! Shared Drive-result activation policy for the GTK browser and quick prompt. +//! +//! Media should be handed to its mounted path so players can issue range reads +//! through FUSE. Other files retain the materialize-then-open behaviour because +//! many desktop applications expect a stable ordinary file. + +use std::path::{Path, PathBuf}; + +use pdfs_core::control::PhotoKind; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum DriveActivation { + Folder, + MountedMedia, + Materialize, +} + +pub(crate) fn drive_activation(name: &str, is_dir: bool) -> DriveActivation { + if is_dir { + DriveActivation::Folder + } else if is_streamable_media(name) { + DriveActivation::MountedMedia + } else { + DriveActivation::Materialize + } +} + +pub(crate) fn mounted_path(mountpoint: &Path, relative_path: &str) -> PathBuf { + mountpoint.join(relative_path) +} + +fn is_streamable_media(name: &str) -> bool { + if PhotoKind::classify(Some(name), None) == PhotoKind::Video { + return true; + } + + let extension = Path::new(name) + .extension() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + + matches!( + extension.to_ascii_lowercase().as_str(), + "mp3" | "flac" | "wav" | "ogg" | "opus" | "m4a" | "aac" + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn folders_use_the_mount_regardless_of_extension() { + assert_eq!( + drive_activation("recordings.mp4", true), + DriveActivation::Folder + ); + } + + #[test] + fn media_extensions_use_fuse_path_case_insensitively() { + for name in ["movie.MKV", "voice.opus", "song.FLAC", "clip.m4v"] { + assert_eq!( + drive_activation(name, false), + DriveActivation::MountedMedia, + "{name}" + ); + } + } + + #[test] + fn ordinary_files_are_materialized() { + for name in ["report.pdf", "notes.txt", "archive.zip", "no-extension"] { + assert_eq!( + drive_activation(name, false), + DriveActivation::Materialize, + "{name}" + ); + } + } + + #[test] + fn mounted_paths_preserve_relative_components() { + assert_eq!( + mounted_path(Path::new("/mnt/drive"), "Videos/a movie.mkv"), + Path::new("/mnt/drive/Videos/a movie.mkv") + ); + } +} diff --git a/crates/pdfs-gui/src/app/main.rs b/crates/pdfs-gui/src/app/main.rs index e6dff9d..50a30bb 100644 --- a/crates/pdfs-gui/src/app/main.rs +++ b/crates/pdfs-gui/src/app/main.rs @@ -1,3 +1,5 @@ +#[path = "../activation.rs"] +pub(crate) mod activation; pub(crate) mod pages; pub(crate) mod widgets; diff --git a/crates/pdfs-gui/src/app/pages/browser.rs b/crates/pdfs-gui/src/app/pages/browser.rs index ff136f2..c02c35a 100644 --- a/crates/pdfs-gui/src/app/pages/browser.rs +++ b/crates/pdfs-gui/src/app/pages/browser.rs @@ -1,3 +1,4 @@ +use crate::activation::{DriveActivation, drive_activation, mounted_path}; use crate::*; pub(crate) struct BrowserState { @@ -493,9 +494,9 @@ pub(crate) fn show_context_menu(ui: &Rc, entry: &DirEntry, anchor: >k4::Bo popover.set_parent(anchor); popover.connect_closed(|p| p.unparent()); - // Videos lead with Play (stream from the mount, no download); the plain Open + // Media leads with Play (stream from the mount, no download); the plain Open // below still downloads a local copy for anyone who wants one. - if is_video_entry(entry) { + if is_streamable_media_entry(entry) { let play = menu_item("Play (stream)", "media-playback-start-symbolic"); let ui_play = ui.clone(); let entry_play = entry.clone(); @@ -514,8 +515,8 @@ pub(crate) fn show_context_menu(ui: &Rc, entry: &DirEntry, anchor: >k4::Bo open.connect_clicked(move |_| { pop.popdown(); // Open always means "download a local copy and hand off", even for a - // video — `activate_entry` would otherwise stream it. - if is_video_entry(&entry_open) { + // media file — `activate_entry` would otherwise stream it. + if is_streamable_media_entry(&entry_open) { download_and_open(&ui_open, &entry_open); } else { activate_entry(&ui_open, &entry_open); @@ -610,14 +611,12 @@ pub(crate) fn entry_at(model: Option<&impl IsA>, pos: u32) -> Op Some(entry) } -/// Whether an entry's name marks it as a video, reusing the same classifier the -/// Photos page splits on — so the file browser and the gallery agree on what a -/// video is. -pub(crate) fn is_video_entry(entry: &DirEntry) -> bool { - !entry.is_dir && PhotoKind::classify(Some(&entry.name), None) == PhotoKind::Video +/// Whether an entry can be opened through FUSE instead of fully materialized. +pub(crate) fn is_streamable_media_entry(entry: &DirEntry) -> bool { + drive_activation(&entry.name, entry.is_dir) == DriveActivation::MountedMedia } -/// Stream a video straight from the mount, no download. Drive folders *are* part +/// Stream media straight from the mount, no download. Drive folders *are* part /// of the FUSE mount, so a player pointed at `/` reads the file /// through [`Core::read_range_remote`] — 4 MB blocks fetched on demand as it /// seeks and buffers — instead of waiting for the whole file to land like @@ -626,11 +625,11 @@ pub(crate) fn is_video_entry(entry: &DirEntry) -> bool { pub(crate) fn stream_entry(ui: &Rc, entry: &DirEntry) { let rel = entry_rel(ui, entry); let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config()); - let abs = mountpoint.join(&rel); + let abs = mounted_path(&mountpoint, &rel); let Some(path) = abs.to_str() else { toast_error( ui, - "Couldn't play video", + "Couldn't play media", "The file path isn't valid UTF-8.", ); return; @@ -639,7 +638,7 @@ pub(crate) fn stream_entry(ui: &Rc, entry: &DirEntry) { play_external(path); } -/// Open an entry the Nautilus way: folders descend, videos stream from the mount, +/// Open an entry the Nautilus way: folders descend, media streams from the mount, /// other files download-and-open. pub(crate) fn activate_entry(ui: &Rc, entry: &DirEntry) { let rel = entry_rel(ui, entry); @@ -651,8 +650,8 @@ pub(crate) fn activate_entry(ui: &Rc, entry: &DirEntry) { } *ui.browser.path.borrow_mut() = rel; load_browser(ui); - } else if is_video_entry(entry) { - // A video streams rather than downloads: that is exactly the "play it, + } else if drive_activation(&entry.name, entry.is_dir) == DriveActivation::MountedMedia { + // Media streams rather than downloads: that is exactly the "play it, // don't fetch the whole thing" behaviour this is for. stream_entry(ui, entry); } else { diff --git a/crates/pdfs-gui/src/prompt.rs b/crates/pdfs-gui/src/prompt.rs index 56850ee..a41ff11 100644 --- a/crates/pdfs-gui/src/prompt.rs +++ b/crates/pdfs-gui/src/prompt.rs @@ -1,11 +1,11 @@ //! `pdfs-prompt` — the launcher: one search field over both Proton Drive and the //! files on this machine, styled after Google Drive's search overlay. //! -//! Bind it to a system shortcut (e.g. in Hyprland) for a quick HUD search. It is -//! a short-lived process: it must draw *now*, so nothing here blocks the main -//! loop. Every daemon round-trip runs on a worker thread and lands back through -//! an async channel; the two searches (Drive and local) are fired in parallel and -//! each section renders as its own reply arrives. +//! Bind it to a system shortcut (e.g. in Hyprland) for a quick HUD search. The +//! application is single-instance and keeps its window alive between summons, +//! so repeat activations only reset and present the existing widget tree. Every +//! daemon round-trip runs on a worker thread and lands back through an async +//! channel. Drive and local lookup share one request so a result set paints once. use std::cell::{Cell, RefCell}; use std::path::{Path, PathBuf}; @@ -17,7 +17,12 @@ use adw::prelude::*; use gtk4::{gio, glib}; use pdfs_core::config::AppDirs; -use pdfs_core::control::{LocalHit, Request, Response, SearchHit, send}; +use pdfs_core::control::{ + LocalHit, Request, Response, SearchFilters, SearchHit, SearchKind, SearchSource, send, +}; + +mod activation; +use activation::{DriveActivation, drive_activation, mounted_path}; const APP_ID: &str = "io.narl.proton-drive-linux-prompt"; @@ -60,6 +65,16 @@ impl Filter { Filter::Media => !is_dir && is_media(name), } } + + fn search_kind(self) -> SearchKind { + match self { + Self::All => SearchKind::All, + Self::Folders => SearchKind::Folders, + Self::Documents => SearchKind::Documents, + Self::Images => SearchKind::Images, + Self::Media => SearchKind::Media, + } + } } fn extension(name: &str) -> String { @@ -174,6 +189,13 @@ impl Hit { matches!(self, Hit::Drive(h) if h.pinned) } + fn score(&self) -> i64 { + match self { + Self::Drive(hit) => hit.score, + Self::Local(hit) => hit.score, + } + } + /// Stable identity used to preserve the keyboard cursor across re-renders. /// The bool keeps a Drive and a local hit at the same path distinct. fn key(&self) -> (bool, String) { @@ -184,6 +206,15 @@ impl Hit { } } +fn rank_hits(hits: &mut [Hit]) { + hits.sort_by(|a, b| { + b.score() + .cmp(&a.score()) + .then_with(|| a.name().cmp(b.name())) + .then_with(|| a.key().cmp(&b.key())) + }); +} + /// Everything the window needs to render and act, in one place so the many /// callbacks below capture a single `Rc` instead of a dozen clones each. struct Ui { @@ -195,6 +226,7 @@ struct Ui { stack: gtk4::Stack, scroller: gtk4::ScrolledWindow, results: gtk4::Box, + search_section: Section, drive_section: Section, local_section: Section, placeholder: adw::StatusPage, @@ -217,8 +249,7 @@ struct Ui { /// Monotonic query id. A reply whose id is stale (the user typed again while /// it was in flight) is dropped instead of overwriting fresher results. query_id: Cell, - /// In-flight searches for the current query id, so the spinner stops only - /// once both sections have answered. + /// In-flight requests for the current query id. pending: Cell, /// True once a Drive open is running: the window is about to close, so /// further input is ignored. @@ -229,6 +260,10 @@ struct Ui { /// against the selected row once the fresh results settle, rather than being /// dropped — so a plain "type, Enter" always opens something. open_pending: Cell, + /// Prevent lifecycle-driven entry resets from also scheduling the normal + /// debounced empty-query request; activation performs one explicit daemon + /// bootstrap instead. + suppress_entry_change: Cell, /// The window, so a deferred open (above) can reach it from `render`. window: RefCell>, } @@ -301,17 +336,33 @@ fn main() -> glib::ExitCode { .init(); let app = adw::Application::builder().application_id(APP_ID).build(); + // GtkApplication normally remains active while it owns a window, including + // when that window is hidden. Hold it explicitly as well: residency is a + // product requirement here, not an incidental window-lifetime side effect. + let _resident = app.hold(); app.connect_startup(|_| load_css()); - app.connect_activate(build_window); + let prompt: Rc>>> = Rc::new(RefCell::new(None)); + app.connect_activate(move |app| { + let ui = if let Some(ui) = prompt.borrow().clone() { + ui + } else { + let Some(ui) = build_window(app) else { + return; + }; + *prompt.borrow_mut() = Some(ui.clone()); + ui + }; + ui.activate(); + }); app.run() } -fn build_window(app: &adw::Application) { +fn build_window(app: &adw::Application) -> Option> { let dirs = match AppDirs::new() { Ok(d) => d, Err(e) => { tracing::error!("cannot resolve app dirs: {e}"); - return; + return None; } }; @@ -362,8 +413,10 @@ fn build_window(app: &adw::Application) { let results = gtk4::Box::new(gtk4::Orientation::Vertical, 0); results.add_css_class("results"); + let search_section = Section::new("Best matches", "system-search-symbolic"); let drive_section = Section::new("Proton Drive", "folder-remote-symbolic"); let local_section = Section::new("This computer", "drive-harddisk-symbolic"); + search_section.attach(&results); drive_section.attach(&results); local_section.attach(&results); @@ -430,6 +483,7 @@ fn build_window(app: &adw::Application) { stack, scroller, results, + search_section, drive_section, local_section, placeholder, @@ -446,6 +500,7 @@ fn build_window(app: &adw::Application) { opening: Cell::new(false), indexing: Cell::new(false), open_pending: Cell::new(false), + suppress_entry_change: Cell::new(false), window: RefCell::new(None), }); *ui.window.borrow_mut() = Some(window.clone()); @@ -477,15 +532,17 @@ fn build_window(app: &adw::Application) { (&ui.local_section.list, true), ] { let ui_row = ui.clone(); - let window_row = window.clone(); list.connect_row_activated(move |_, row| { let offset = if is_local { ui_row.drive_count() } else { 0 }; - ui_row.open(offset + row.index() as usize, &window_row); + ui_row.open(offset + row.index() as usize); }); } + let ui_row = ui.clone(); + ui.search_section + .list + .connect_row_activated(move |_, row| ui_row.open(row.index() as usize)); let ui_key = ui.clone(); - let window_key = window.clone(); let keys = gtk4::EventControllerKey::new(); keys.connect_key_pressed(move |_, key, _, state| { if ui_key.opening.get() { @@ -496,7 +553,7 @@ fn build_window(app: &adw::Application) { // Spotlight-style: first Escape clears a non-empty query, a // second (or an already-empty box) dismisses the launcher. if ui_key.entry.text().is_empty() { - window_key.close(); + ui_key.dismiss(); } else { ui_key.entry.set_text(""); } @@ -532,19 +589,41 @@ fn build_window(app: &adw::Application) { // focused entry is never reached. Enter with focus in the list is covered by // `row_activated` above. let ui_activate = ui.clone(); - let window_activate = window.clone(); entry.connect_activate(move |_| { if ui_activate.opening.get() { return; } // Fall back to the top row when nothing is explicitly selected (e.g. // results haven't rendered yet); `open` guards the rest. - ui_activate.open(ui_activate.cursor.get().unwrap_or(0), &window_activate); + ui_activate.open(ui_activate.cursor.get().unwrap_or(0)); }); + // Some GTK/input-method combinations consume the physical Return binding in + // GtkText without emitting Entry::activate. Capture only Return ahead of the + // text widget and leave every other key to normal GTK/IME handling. Stopping + // propagation also prevents the activate signal above from opening twice; + // that signal remains the programmatic/accessibility activation path. + let ui_return = ui.clone(); + let return_key = gtk4::EventControllerKey::new(); + return_key.set_propagation_phase(gtk4::PropagationPhase::Capture); + return_key.connect_key_pressed(move |_, key, _, _| { + if matches!(key, gtk4::gdk::Key::Return | gtk4::gdk::Key::KP_Enter) { + if !ui_return.opening.get() { + ui_return.open(ui_return.cursor.get().unwrap_or(0)); + } + glib::Propagation::Stop + } else { + glib::Propagation::Proceed + } + }); + entry.add_controller(return_key); + let debounce: Rc>> = Rc::new(RefCell::new(None)); let ui_changed = ui.clone(); entry.connect_changed(move |entry| { + if ui_changed.suppress_entry_change.get() { + return; + } // A fresh keystroke supersedes any Enter that was waiting on results: // the user is still refining the query, not asking to open yet. ui_changed.open_pending.set(false); @@ -564,22 +643,59 @@ fn build_window(app: &adw::Application) { let ui_retry = ui.clone(); retry.connect_clicked(move |_| ui_retry.connect()); - // A launcher is modal by convention: losing focus dismisses it. - window.connect_is_active_notify(|w| { - if !w.is_active() { - w.close(); - } + // Keep the widget tree alive when the compositor asks the window to close. + // This also avoids running GTK's destruction path while async replies still + // hold references to prompt widgets. + let ui_close = ui.clone(); + window.connect_close_request(move |_| { + ui_close.dismiss(); + glib::Propagation::Stop }); let entry_map = entry.clone(); window.connect_map(move |_| { entry_map.grab_focus(); }); - ui.connect(); - window.present(); + Some(ui) } impl Ui { + /// Reset and present the resident prompt in response to GApplication + /// activation. A second invocation while a file is materialising merely + /// raises the progress window; it must not make the in-flight open mutable. + fn activate(self: &Rc) { + let Some(window) = self.window.borrow().clone() else { + return; + }; + if !self.opening.get() { + // Supersede replies from the previous showing before clearing the + // query. The daemon work may finish, but it can no longer repaint + // the newly activated prompt with stale rows. + self.query_id.set(self.query_id.get() + 1); + self.pending.set(0); + self.open_pending.set(false); + self.spinner.stop(); + self.spinner.set_visible(false); + self.suppress_entry_change.set(true); + self.entry.set_text(""); + self.suppress_entry_change.set(false); + self.set_filter(Filter::All); + self.connect(); + } + window.present(); + self.entry.grab_focus(); + } + + /// Dismiss without destroying the application window. GTK continues to + /// associate the hidden window with the single application instance, so a + /// later desktop shortcut invocation is a cheap `activate` round-trip. + fn dismiss(&self) { + self.open_pending.set(false); + if let Some(window) = self.window.borrow().as_ref() { + window.set_visible(false); + } + } + /// Ask the daemon for status and, if it answers, load the suggested (pinned) /// files. Runs off-thread: a dead daemon must not freeze the first frame. fn connect(self: &Rc) { @@ -640,6 +756,7 @@ impl Ui { modified: 0, pinned: true, uid: pin.uid, + score: 0, }) .collect(), _ => Vec::new(), @@ -650,32 +767,41 @@ impl Ui { }); } - /// Fire both searches for `query` in parallel. An empty query falls back to - /// the suggestions view. + /// Search Drive and local metadata in one daemon round-trip. An empty query + /// falls back to the suggestions view. fn search(self: &Rc, query: &str) { if query.is_empty() { self.load_suggestions(); return; } - let id = self.begin_query(2); - - let drive = spawn_request( + let id = self.begin_query(1); + let search = spawn_request( self.socket.clone(), - Request::Search { + Request::SearchV2 { query: query.to_string(), limit: SEARCH_LIMIT, + filters: SearchFilters { + sources: vec![SearchSource::Drive, SearchSource::Local], + kind: self.filter.get().search_kind(), + }, }, ); let ui = self.clone(); glib::spawn_future_local(async move { - let reply = drive.recv().await; + let reply = search.recv().await; if !ui.finish_query(id) { return; } match reply { - Ok(Ok(Response::SearchResults { hits })) => { - *ui.drive_hits.borrow_mut() = hits; + Ok(Ok(Response::SearchResultsV2 { + drive_hits, + local_hits, + local_indexing, + })) => { + *ui.drive_hits.borrow_mut() = drive_hits; + *ui.local_hits.borrow_mut() = local_hits; + ui.indexing.set(local_indexing); ui.render(); } // Transport failure: the daemon is gone. Don't paint "No @@ -683,32 +809,6 @@ impl Ui { Ok(Err(_)) | Err(_) => ui.go_offline(), _ => { ui.drive_hits.borrow_mut().clear(); - ui.render(); - } - } - }); - - let local = spawn_request( - self.socket.clone(), - Request::SearchLocal { - query: query.to_string(), - limit: SEARCH_LIMIT, - }, - ); - let ui = self.clone(); - glib::spawn_future_local(async move { - let reply = local.recv().await; - if !ui.finish_query(id) { - return; - } - match reply { - Ok(Ok(Response::LocalResults { hits, indexing })) => { - ui.indexing.set(indexing); - *ui.local_hits.borrow_mut() = hits; - ui.render(); - } - Ok(Err(_)) | Err(_) => ui.go_offline(), - _ => { ui.local_hits.borrow_mut().clear(); ui.render(); } @@ -751,7 +851,12 @@ impl Ui { for (chip_filter, chip) in self.chips.borrow().iter() { chip.set_active(*chip_filter == filter); } - self.render(); + let query = self.entry.text().trim().to_string(); + if query.is_empty() { + self.render(); + } else { + self.search(&query); + } } fn cycle_filter(self: &Rc, backwards: bool) { @@ -776,9 +881,8 @@ impl Ui { .count() } - /// Rebuild both sections from the last replies under the active chip, then - /// restore the cursor to the top. Every state change funnels through here, so - /// the list, the placeholder, and the footer can never disagree. + /// Rebuild suggestions or the globally-ranked search section, preserving the + /// selected identity when a result survives the update. fn render(self: &Rc) { let filter = self.filter.get(); @@ -808,17 +912,24 @@ impl Ui { let query = self.entry.text().trim().to_string(); let searching = !query.is_empty(); - let drive_title = if searching { - "Proton Drive" - } else { - "Pinned in Proton Drive" - }; - self.drive_section.set_rows(&drive, Some(drive_title)); - self.local_section.set_rows(&local, None); - let total = drive.len() + local.len(); let mut visible = drive; visible.extend(local); + if searching { + rank_hits(&mut visible); + self.search_section.set_rows(&visible, Some("Best matches")); + self.drive_section.set_rows(&[], None); + self.local_section.set_rows(&[], None); + } else { + self.search_section.set_rows(&[], None); + let drive_count = visible + .iter() + .take_while(|hit| matches!(hit, Hit::Drive(_))) + .count(); + self.drive_section + .set_rows(&visible[..drive_count], Some("Pinned in Proton Drive")); + self.local_section.set_rows(&visible[drive_count..], None); + } // Find where the previously-selected row landed in the rebuilt list. let restored = selected_key.and_then(|key| visible.iter().position(|hit| hit.key() == key)); @@ -854,8 +965,8 @@ impl Ui { // so it lands on the final list, not a half-populated one. if self.open_pending.get() && self.pending.get() == 0 { self.open_pending.set(false); - if let (Some(index), Some(window)) = (self.cursor.get(), self.window.borrow().clone()) { - self.open(index, &window); + if let Some(index) = self.cursor.get() { + self.open(index); } } } @@ -892,6 +1003,19 @@ impl Ui { /// section owns it, clear the other section's selection, and scroll it into /// view. Focus stays in the entry so typing never breaks. fn select(self: &Rc, index: usize) { + if !self.rendered_query.borrow().is_empty() { + self.drive_section.list.unselect_all(); + self.local_section.list.unselect_all(); + let Some(row) = self.search_section.list.row_at_index(index as i32) else { + return; + }; + self.search_section.list.select_row(Some(&row)); + self.cursor.set(Some(index)); + self.scroll_into_view(&row); + return; + } + + self.search_section.list.unselect_all(); let drive_count = self.drive_count(); let (list, other, local_index) = if index < drive_count { ( @@ -938,7 +1062,7 @@ impl Ui { /// straight to the desktop; a Drive hit must first be hydrated into the cache /// by the daemon (`OpenFile`), which can take a moment — the window stays up, /// with the spinner running, until the path comes back. - fn open(self: &Rc, index: usize, window: &adw::ApplicationWindow) { + fn open(self: &Rc, index: usize) { if self.opening.get() { return; } @@ -957,15 +1081,30 @@ impl Ui { match hit { Hit::Local(local) => { xdg_open(Path::new(&local.path)); - window.close(); - } - // A Drive folder exists in the mount — open it in the file manager - // rather than downloading anything. - Hit::Drive(drive) if drive.is_dir => { - xdg_open(&self.mountpoint.borrow().join(&drive.path)); - window.close(); + self.dismiss(); } Hit::Drive(drive) => { + // Empty-query rows come from ListPins, whose `recursive` flag is + // pin policy rather than reliable node kind metadata. Every pin + // already has a mounted path, so activate that path directly; + // this handles non-recursively pinned folders without mistaking + // them for files and sending an invalid OpenFile request. + if self.rendered_query.borrow().is_empty() { + let path = mounted_path(&self.mountpoint.borrow(), &drive.path); + xdg_open(&path); + self.dismiss(); + return; + } + match drive_activation(&drive.name, drive.is_dir) { + DriveActivation::Folder | DriveActivation::MountedMedia => { + let path = mounted_path(&self.mountpoint.borrow(), &drive.path); + xdg_open(&path); + self.dismiss(); + return; + } + DriveActivation::Materialize => {} + } + self.opening.set(true); self.spinner.set_visible(true); self.spinner.start(); @@ -978,7 +1117,6 @@ impl Ui { }, ); let ui = self.clone(); - let window = window.clone(); glib::spawn_future_local(async move { let reply = rx.recv().await; ui.spinner.stop(); @@ -987,7 +1125,7 @@ impl Ui { match reply { Ok(Ok(Response::FilePath { path })) => { xdg_open(Path::new(&path)); - window.close(); + ui.dismiss(); } Ok(Ok(Response::Error { message, .. })) => { ui.hint.set_label(&format!("Could not open: {message}")); @@ -1288,3 +1426,36 @@ fn load_css() { ); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ranked_hits_merge_sources_on_one_score_scale() { + let mut hits = vec![ + Hit::Drive(SearchHit { + name: "alpha.pdf".into(), + path: "Drive/alpha.pdf".into(), + is_dir: false, + size: 1, + modified: 1, + pinned: false, + uid: "v~a".into(), + score: 100, + }), + Hit::Local(LocalHit { + name: "better.pdf".into(), + path: "/home/me/better.pdf".into(), + is_dir: false, + size: 1, + modified: 1, + score: 300, + }), + ]; + + rank_hits(&mut hits); + assert!(matches!(&hits[0], Hit::Local(hit) if hit.name == "better.pdf")); + assert!(matches!(&hits[1], Hit::Drive(hit) if hit.name == "alpha.pdf")); + } +} diff --git a/scripts/fuse-acceptance.sh b/scripts/fuse-acceptance.sh index 28010ae..2c486a1 100755 --- a/scripts/fuse-acceptance.sh +++ b/scripts/fuse-acceptance.sh @@ -76,6 +76,37 @@ assert got == expected print(hashlib.sha256(got).hexdigest()) PY +step "media-style header and distant seek reads" +python3 - "$primary/seek-sample.mkv" <<'PY' +import os, sys + +p = sys.argv[1] +size = 12 * 1024 * 1024 + 137 +header = b"\x1aE\xdf\xa3pdfs-media-header" +middle = b"pdfs-middle-cue" +trailer = b"pdfs-media-trailer" +middle_at = 7 * 1024 * 1024 + 19 +trailer_at = size - len(trailer) + +# A player probes the container header, jumps to cues/index data, then commonly +# seeks near EOF. Write a sparse fixture so the acceptance test stays cheap, +# and issue separate unbuffered reads so FUSE receives genuinely distant ranges +# rather than one accidental sequential read. +with open(p, "wb", buffering=0) as f: + f.truncate(size) + f.seek(0); f.write(header) + f.seek(middle_at); f.write(middle) + f.seek(trailer_at); f.write(trailer) + +with open(p, "rb", buffering=0) as f: + assert f.read(len(header)) == header + f.seek(middle_at); assert f.read(len(middle)) == middle + f.seek(trailer_at); assert f.read(len(trailer)) == trailer + assert f.read(1) == b"" + f.seek(size + 4096); assert f.read(16) == b"" +assert os.stat(p).st_size == size +PY + step "rename, move, and replacement semantics" mkdir -- "$primary/dir-a" "$primary/dir-b" printf 'source' > "$primary/source" @@ -141,6 +172,35 @@ if [[ "${PDFS_ACCEPTANCE_CONVERGENCE:-0}" == 1 ]] && (( ${#roots[@]} > 1 )); the [[ "$(sha256sum "$secondary/binary.dat" | cut -d' ' -f1)" == "$expected" ]] || { echo "content mismatch on $secondary" >&2; exit 1 } + + # The media fixture may drain after binary.dat. Wait independently, + # then probe only its sparse ranges on the other mount. Unlike the + # same-mount check above, this exercises remote FUSE hydration and seek + # reads instead of serving the still-local staged file. + deadline=$((SECONDS + ${PDFS_ACCEPTANCE_SYNC_TIMEOUT:-120})) + while (( SECONDS < deadline )); do + [[ -f "$secondary/seek-sample.mkv" ]] && + [[ "$(stat -c %s "$secondary/seek-sample.mkv" 2>/dev/null)" == 12583049 ]] && break + sleep 2 + done + [[ -f "$secondary/seek-sample.mkv" ]] || { + echo "media fixture did not appear on $secondary" >&2; exit 1 + } + python3 - "$secondary/seek-sample.mkv" <<'PY' +import os, sys +p = sys.argv[1] +size = 12 * 1024 * 1024 + 137 +checks = [ + (0, b"\x1aE\xdf\xa3pdfs-media-header"), + (7 * 1024 * 1024 + 19, b"pdfs-middle-cue"), + (size - len(b"pdfs-media-trailer"), b"pdfs-media-trailer"), +] +assert os.stat(p).st_size == size +with open(p, "rb", buffering=0) as f: + for offset, expected in checks: + f.seek(offset) + assert f.read(len(expected)) == expected +PY done fi