From ea56818a807a5d5d6f24111ead0376c8a37ac37d Mon Sep 17 00:00:00 2001 From: Nils Pukropp Date: Wed, 22 Jul 2026 11:27:57 +0200 Subject: [PATCH] prompt refactor --- crates/pdfs-core/src/control.rs | 6 +++ crates/pdfs-core/src/db/nodes.rs | 34 ++++++++++++ crates/pdfs-core/src/db/tests.rs | 91 ++++++++++++++++++++++++++++++++ crates/pdfs-fuse/src/lib.rs | 20 +++++++ crates/pdfs-gui/src/prompt.rs | 12 ++++- 5 files changed, 161 insertions(+), 2 deletions(-) diff --git a/crates/pdfs-core/src/control.rs b/crates/pdfs-core/src/control.rs index 226ae4b..0ea6972 100644 --- a/crates/pdfs-core/src/control.rs +++ b/crates/pdfs-core/src/control.rs @@ -678,6 +678,11 @@ pub struct SearchHit { pub pinned: bool, /// Node uid in `volume~link` form. pub uid: String, + /// Absolute path through the most specific active on-demand mount, when the + /// node is covered by one. Clients should prefer this for folders and + /// streamable media, and fall back to `mountpoint/path` when absent. + #[serde(default)] + pub mounted_path: Option, /// 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)] @@ -1369,6 +1374,7 @@ mod tests { modified: 123, pinned: true, uid: "vol~link".into(), + mounted_path: Some("/home/me/Videos/report.pdf".into()), score: 985, }], local_hits: vec![LocalHit { diff --git a/crates/pdfs-core/src/db/nodes.rs b/crates/pdfs-core/src/db/nodes.rs index c048368..540ad43 100644 --- a/crates/pdfs-core/src/db/nodes.rs +++ b/crates/pdfs-core/src/db/nodes.rs @@ -160,6 +160,40 @@ impl Db { Ok(count > 0) } + /// Resolve `uid` relative to an ancestor node. + /// + /// This is used when a remote subtree has its own local sync mount: the + /// sync folder stores the ancestor UID, while search results identify the + /// selected descendant. Returning `None` for an unrelated or incomplete + /// chain keeps callers from accidentally joining a Drive-wide path onto the + /// wrong mountpoint. The ancestor itself resolves to the empty path. + pub fn path_relative_to(&self, ancestor_uid: &str, uid: &str) -> Result> { + let conn = self.conn.lock(); + let mut stmt = conn.prepare( + "WITH RECURSIVE anc(uid, parent_uid, name, depth) AS ( + SELECT uid, parent_uid, name, 0 FROM nodes WHERE uid = ?1 + UNION ALL + SELECT n.uid, n.parent_uid, n.name, anc.depth + 1 + FROM nodes n JOIN anc ON n.uid = anc.parent_uid + WHERE anc.depth < 1024 + ) + SELECT uid, name FROM anc ORDER BY depth", + )?; + let rows = stmt.query_map(params![uid], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })?; + let mut parts = Vec::new(); + for row in rows { + let (current_uid, name) = row?; + if current_uid == ancestor_uid { + parts.reverse(); + return Ok(Some(parts.join("/"))); + } + parts.push(name); + } + Ok(None) + } + /// Full-text search over node names, newest schema's trigram index giving /// substring (not just prefix) matches. Returns up to `limit` non-trashed /// hits, each with its mountpoint-relative path resolved. Queries shorter diff --git a/crates/pdfs-core/src/db/tests.rs b/crates/pdfs-core/src/db/tests.rs index 83f34c7..cd7a303 100644 --- a/crates/pdfs-core/src/db/tests.rs +++ b/crates/pdfs-core/src/db/tests.rs @@ -5,6 +5,7 @@ use crate::control::{ActivityEntry, ActivityKind}; use crate::localindex::LocalEntry; use proton_drive_rs::proton_sdk::ids::NodeUid; use proton_drive_rs::{Node, NodeKind}; +use serde_json::json; /// `Db::open` applies the pragmas that `open_in_memory` skips, so the WAL /// settings can only be checked against a real file. @@ -1181,6 +1182,96 @@ fn pending_mode_is_queued_until_the_mode_is_reached() { assert_eq!(folder.pending_mode, None); } +#[test] +fn node_path_resolves_relative_to_a_sync_root_uid() { + let db = Db::open_in_memory().unwrap(); + let root = node_from(serde_json::Value::Null, "root", "My Files", json!("Folder")); + let videos = node_from(json!(uid("root")), "videos", "Videos", json!("Folder")); + let anime = node_from(json!(uid("videos")), "anime", "anime", json!("Folder")); + let show = node_from(json!(uid("anime")), "show", "Oshi no Ko", json!("Folder")); + let episode = node_from( + json!(uid("show")), + "episode", + "episode 01.mkv", + json!({"File": { + "media_type": "video/x-matroska", + "total_size_on_storage": 42, + "claimed_size": 42, + "claimed_modification_time": null + }}), + ); + db.upsert_nodes(&[root, videos, anime, show, episode]) + .unwrap(); + + assert_eq!( + db.path_relative_to(&uid("videos").to_string(), &uid("episode").to_string()) + .unwrap() + .as_deref(), + Some("anime/Oshi no Ko/episode 01.mkv") + ); + assert_eq!( + db.path_relative_to(&uid("videos").to_string(), &uid("videos").to_string()) + .unwrap() + .as_deref(), + Some("") + ); + assert_eq!( + db.path_relative_to(&uid("anime").to_string(), &uid("episode").to_string()) + .unwrap() + .as_deref(), + Some("Oshi no Ko/episode 01.mkv") + ); +} + +#[test] +fn node_path_rejects_unrelated_missing_and_broken_ancestor_chains() { + let db = Db::open_in_memory().unwrap(); + let root = node_from(serde_json::Value::Null, "root", "My Files", json!("Folder")); + let videos = node_from(json!(uid("root")), "videos", "Videos", json!("Folder")); + let episode = node_from( + json!(uid("videos")), + "episode", + "episode.mkv", + json!({"File": { + "media_type": "video/x-matroska", + "total_size_on_storage": 42, + "claimed_size": 42, + "claimed_modification_time": null + }}), + ); + let unrelated = node_from( + json!(uid("root")), + "documents", + "Documents", + json!("Folder"), + ); + let orphan = node_from( + json!(uid("missing-parent")), + "orphan", + "orphan.mkv", + json!({"File": { + "media_type": "video/x-matroska", + "total_size_on_storage": 42, + "claimed_size": 42, + "claimed_modification_time": null + }}), + ); + db.upsert_nodes(&[root, videos, episode, unrelated, orphan]) + .unwrap(); + + for (ancestor, descendant) in [ + ("documents", "episode"), + ("videos", "missing-node"), + ("videos", "orphan"), + ] { + assert_eq!( + db.path_relative_to(&uid(ancestor).to_string(), &uid(descendant).to_string()) + .unwrap(), + None + ); + } +} + #[test] fn cache_index_touch_access_and_lru_order() { let db = Db::open_in_memory().unwrap(); diff --git a/crates/pdfs-fuse/src/lib.rs b/crates/pdfs-fuse/src/lib.rs index 68175ba..2237ffd 100644 --- a/crates/pdfs-fuse/src/lib.rs +++ b/crates/pdfs-fuse/src/lib.rs @@ -1784,6 +1784,24 @@ impl Core { .collect()) } + /// Return an absolute path through the most specific live on-demand mount + /// covering `uid`. A node may be below several configured roots, so prefer + /// the root leaving the shortest descendant path. + fn mounted_search_path(&self, uid: &str) -> Option { + let live_mounts = self.mounts.lock(); + let folders = self.db.sync_folder_list().ok()?; + folders + .into_iter() + .filter(|folder| folder.mode == "ondemand" && live_mounts.contains_key(&folder.id)) + .filter_map(|folder| { + let relative = self.db.path_relative_to(&folder.remote_uid, uid).ok()??; + let depth = relative.split('/').count(); + Some((depth, PathBuf::from(folder.local_path).join(relative))) + }) + .min_by_key(|(depth, _)| *depth) + .map(|(_, path)| path.to_string_lossy().into_owned()) + } + /// 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. @@ -1802,6 +1820,7 @@ impl Core { modified: h.node.modification_time, pinned: self.cache.is_pinned(&h.node.uid), uid: h.node.uid.to_string(), + mounted_path: self.mounted_search_path(&h.node.uid.to_string()), score: 0, }) .collect()) @@ -1865,6 +1884,7 @@ impl Core { modified: hit.node.modification_time, pinned, uid: hit.node.uid.to_string(), + mounted_path: self.mounted_search_path(&hit.node.uid.to_string()), score, }) }) diff --git a/crates/pdfs-gui/src/prompt.rs b/crates/pdfs-gui/src/prompt.rs index a41ff11..c5edea8 100644 --- a/crates/pdfs-gui/src/prompt.rs +++ b/crates/pdfs-gui/src/prompt.rs @@ -756,6 +756,7 @@ impl Ui { modified: 0, pinned: true, uid: pin.uid, + mounted_path: None, score: 0, }) .collect(), @@ -1090,14 +1091,20 @@ impl Ui { // 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); + let path = drive.mounted_path.as_deref().map_or_else( + || mounted_path(&self.mountpoint.borrow(), &drive.path), + PathBuf::from, + ); 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); + let path = drive.mounted_path.as_deref().map_or_else( + || mounted_path(&self.mountpoint.borrow(), &drive.path), + PathBuf::from, + ); xdg_open(&path); self.dismiss(); return; @@ -1442,6 +1449,7 @@ mod tests { modified: 1, pinned: false, uid: "v~a".into(), + mounted_path: None, score: 100, }), Hit::Local(LocalHit {