prompt refactor

This commit is contained in:
nvrl committed 2026-07-22 09:50:42 +02:00
1 parent 6cba77d3cb
commit 39ab4aae48
14 files changed
+1409 -111

No files matched your search

+321
View File
@@ -101,6 +101,19 @@ pub enum Request {
/// Independent of [`Request::Search`] so a front-end can fire both at once and /// Independent of [`Request::Search`] so a front-end can fire both at once and
/// render whichever lands first. Replies with [`Response::LocalResults`]. /// render whichever lands first. Replies with [`Response::LocalResults`].
SearchLocal { query: String, limit: usize }, 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 /// Rename a file or folder. `path` is mountpoint-relative; `new_name` is a
/// single path component (no separators). Replies with [`Response::Ok`]. /// single path component (no separators). Replies with [`Response::Ok`].
Rename { path: String, new_name: String }, Rename { path: String, new_name: String },
@@ -665,6 +678,10 @@ pub struct SearchHit {
pub pinned: bool, pub pinned: bool,
/// Node uid in `volume~link` form. /// Node uid in `volume~link` form.
pub uid: String, 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 /// One hit in a [`Request::SearchLocal`] result: a file on this machine, outside
@@ -679,6 +696,107 @@ pub struct LocalHit {
pub size: u64, pub size: u64,
/// Modification time, epoch seconds. /// Modification time, epoch seconds.
pub modified: i64, 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<SearchSource>,
#[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<SearchSource> {
vec![SearchSource::Drive, SearchSource::Local]
} }
/// What kind of media a timeline entry is, so the Photos page can split into /// 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 /// 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. /// say "still indexing" instead of "no matches" on a cold first launch.
LocalResults { hits: Vec<LocalHit>, indexing: bool }, LocalResults { hits: Vec<LocalHit>, 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<SearchHit>,
local_hits: Vec<LocalHit>,
local_indexing: bool,
},
/// A snapshot of what the daemon is working on (reply to /// A snapshot of what the daemon is working on (reply to
/// [`Request::GetQueueStatus`]): `items` are byte-moving transfers, `jobs` /// [`Request::GetQueueStatus`]): `items` are byte-moving transfers, `jobs`
/// the longer non-transfer work around them (scans, folder skeletons, the /// 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); 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] #[test]
fn photo_kind_i64_round_trips() { fn photo_kind_i64_round_trips() {
for k in [PhotoKind::Photo, PhotoKind::Video, PhotoKind::Raw] { 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 /// 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 /// line-delimited round-trip, since a mangled uid would restore or destroy the
/// wrong node. /// wrong node.
+53
View File
@@ -7,6 +7,7 @@ use super::Db;
use crate::Result; use crate::Result;
use crate::localindex::LocalEntry; use crate::localindex::LocalEntry;
use super::nodes::candidate_trigrams;
use super::utils::{TRIGRAM_MIN, like_escape}; use super::utils::{TRIGRAM_MIN, like_escape};
/// One hit from [`Db::search_local`]: an indexed file on the machine itself, not /// One hit from [`Db::search_local`]: an indexed file on the machine itself, not
@@ -147,6 +148,58 @@ impl Db {
} }
Ok(out) 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<Vec<LocalFileHit>> {
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<LocalFileHit> = 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::<std::result::Result<_, _>>()?
} 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::<std::result::Result<_, _>>()?
};
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::<std::result::Result<Vec<_>, _>>()?;
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 /// Row → [`LocalFileHit`]. Every local-search query selects the same
+38 -1
View File
@@ -9,7 +9,7 @@ use super::Db;
use crate::Result; use crate::Result;
/// Current schema version. Bump on every forward migration added below. /// 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 { impl Db {
pub(super) fn migrate(&self) -> Result<()> { pub(super) fn migrate(&self) -> Result<()> {
@@ -81,6 +81,9 @@ impl Db {
if current < 15 { if current < 15 {
tx.execute_batch(MIGRATION_V15)?; tx.execute_batch(MIGRATION_V15)?;
} }
if current < 16 {
tx.execute_batch(MIGRATION_V16)?;
}
tx.execute( tx.execute(
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1) "INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value", 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) INSERT INTO nodes_fts (rowid, name)
SELECT rowid, name FROM nodes WHERE trashed = 0; 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');
";
+114 -5
View File
@@ -81,12 +81,42 @@ impl Db {
params![uid], params![uid],
|row| row.get(0), |row| row.get(0),
)?; )?;
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![rowid])?; // A folder rename/move changes every descendant's searchable path.
if !node.trashed { // Refresh the affected subtree, while retaining efficient rowid
tx.execute( // deletes for the overwhelmingly common one-file case.
"INSERT INTO nodes_fts (rowid, name) VALUES (?1, ?2)", let mut affected = vec![rowid];
params![rowid, node.name], 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()?; tx.commit()?;
@@ -175,6 +205,68 @@ impl Db {
Ok(hits) 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<Vec<SearchHit>> {
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 /// 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 /// rehydrates its `children` map on mount even when empty; an unlisted one
/// re-enumerates from the remote on next access. /// 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. // 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<String> {
let chars: Vec<char> = 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(&quoted) {
terms.push(quoted);
}
}
terms
}
/// Effective plaintext size of a node for the indexed `size` column: the /// Effective plaintext size of a node for the indexed `size` column: the
/// claimed size when known, else the on-storage size; folders are 0. /// claimed size when known, else the on-storage size; folders are 0.
pub(super) fn node_size(node: &Node) -> i64 { pub(super) fn node_size(node: &Node) -> i64 {
+89
View File
@@ -542,6 +542,95 @@ fn search_short_query_like_fallback() {
assert_eq!(hits[0].node.name, "ab.txt"); 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] #[test]
fn search_drops_fts_row_on_delete_and_trash() { fn search_drops_fts_row_on_delete_and_trash() {
let db = Db::open_in_memory().unwrap(); let db = Db::open_in_memory().unwrap();
+1
View File
@@ -10,6 +10,7 @@ pub mod db;
pub mod error; pub mod error;
pub mod localindex; pub mod localindex;
pub mod profile; pub mod profile;
pub mod search;
pub mod service; pub mod service;
pub mod shell; pub mod shell;
pub mod syncignore; pub mod syncignore;
+214
View File
@@ -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<i64> {
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<i64> {
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<usize> {
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);
}
}
+12
View File
@@ -241,6 +241,18 @@ fn handle_control_conn(core: &Core, username: &str, mountpoint: &Path, stream: U
}, },
Err(e) => CtlResponse::error(e), 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(CtlRequest::Rename { path, new_name }) => match rel_to_mount(mountpoint, &path) {
Ok(rel) => match core.rename(&rel, &new_name) { Ok(rel) => match core.rename(&rel, &new_name) {
Ok(name) => { Ok(name) => {
+148 -7
View File
@@ -35,6 +35,7 @@ use parking_lot::{Condvar, Mutex};
use std::collections::{HashMap, HashSet, VecDeque}; use std::collections::{HashMap, HashSet, VecDeque};
use std::ffi::OsStr; use std::ffi::OsStr;
use std::fs::File; use std::fs::File;
use std::io::Write;
use std::os::unix::fs::FileExt; use std::os::unix::fs::FileExt;
use std::os::unix::net::UnixListener; use std::os::unix::net::UnixListener;
use std::path::{Component, Path, PathBuf}; 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::config::AppDirs;
use pdfs_core::control::{ use pdfs_core::control::{
ActivityEntry, ActivityKind, DirEntry, ErrorKind, LocalHit, PhotoKind, PublicLinkInfo, ActivityEntry, ActivityKind, DirEntry, ErrorKind, LocalHit, PhotoKind, PublicLinkInfo,
SearchHit, SyncFolderInfo, SyncPhase, SyncProgress, TransferDirection, SearchFilters, SearchHit, SearchSource, SyncFolderInfo, SyncPhase, SyncProgress,
TransferDirection,
}; };
use pdfs_core::db::{ use pdfs_core::db::{
Db, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp, StoredNode, Db, LOCAL_VOLUME, OP_CREATE, OP_MKDIR, OP_RENAME, OP_REVISION, OP_TRASH, PendingOp, StoredNode,
StoredSyncFolder, StoredTrash, StoredSyncFolder, StoredTrash,
}; };
use pdfs_core::localindex; use pdfs_core::localindex;
use pdfs_core::search::relevance_score;
use pdfs_core::{CoreError, CoreResult}; use pdfs_core::{CoreError, CoreResult};
use proton_drive_rs::proton_sdk::api::ResponseCode; use proton_drive_rs::proton_sdk::api::ResponseCode;
use proton_drive_rs::proton_sdk::error::ProtonError; use proton_drive_rs::proton_sdk::error::ProtonError;
@@ -1524,6 +1527,27 @@ impl Core {
Ok(out.into_inner()) 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<W: Write>(
&self,
uid: &NodeUid,
name: &str,
total: u64,
out: W,
) -> std::result::Result<W, ProtonError> {
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 /// Like [`download_file_tracked`] for a photo, streaming through the photos
/// client's [`download_photo_to`]. /// client's [`download_photo_to`].
/// ///
@@ -1778,6 +1802,7 @@ impl Core {
modified: h.node.modification_time, modified: h.node.modification_time,
pinned: self.cache.is_pinned(&h.node.uid), pinned: self.cache.is_pinned(&h.node.uid),
uid: h.node.uid.to_string(), uid: h.node.uid.to_string(),
score: 0,
}) })
.collect()) .collect())
} }
@@ -1798,10 +1823,95 @@ impl Core {
is_dir: h.is_dir, is_dir: h.is_dir,
size: h.size.max(0) as u64, size: h.size.max(0) as u64,
modified: h.mtime, modified: h.mtime,
score: 0,
}) })
.collect()) .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<SearchHit>, Vec<LocalHit>)> {
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. /// A page of the photos timeline (newest first), sliced out of the DB.
/// `Ok(None)` when the account has no photos volume. /// `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) { if let Some(p) = self.cache.cached_content_path(&uid, mtime, size) {
return Ok(p); return Ok(p);
} }
let bytes = self
.download_file_tracked(&uid, &name, size) // Keep the temporary file beside the final cache blob. `store_file`
.map_err(|e| CoreError::from_api(&e, "download"))?; // can then adopt it with a hard link and atomic rename, while the SDK
self.cache // streams decrypted blocks to disk instead of building a file-sized
.store(&uid, mtime, size, &bytes) // `Vec<u8>`. A unique name also keeps concurrent opens of the same node
.map_err(|e| CoreError::internal(format!("cache store: {e}")))?; // 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)) Ok(self.cache.content_path(&uid))
} }
+89
View File
@@ -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")
);
}
}
+2
View File
@@ -1,3 +1,5 @@
#[path = "../activation.rs"]
pub(crate) mod activation;
pub(crate) mod pages; pub(crate) mod pages;
pub(crate) mod widgets; pub(crate) mod widgets;
+14 -15
View File
@@ -1,3 +1,4 @@
use crate::activation::{DriveActivation, drive_activation, mounted_path};
use crate::*; use crate::*;
pub(crate) struct BrowserState { pub(crate) struct BrowserState {
@@ -493,9 +494,9 @@ pub(crate) fn show_context_menu(ui: &Rc<Ui>, entry: &DirEntry, anchor: &gtk4::Bo
popover.set_parent(anchor); popover.set_parent(anchor);
popover.connect_closed(|p| p.unparent()); 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. // 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 play = menu_item("Play (stream)", "media-playback-start-symbolic");
let ui_play = ui.clone(); let ui_play = ui.clone();
let entry_play = entry.clone(); let entry_play = entry.clone();
@@ -514,8 +515,8 @@ pub(crate) fn show_context_menu(ui: &Rc<Ui>, entry: &DirEntry, anchor: &gtk4::Bo
open.connect_clicked(move |_| { open.connect_clicked(move |_| {
pop.popdown(); pop.popdown();
// Open always means "download a local copy and hand off", even for a // Open always means "download a local copy and hand off", even for a
// video — `activate_entry` would otherwise stream it. // media file — `activate_entry` would otherwise stream it.
if is_video_entry(&entry_open) { if is_streamable_media_entry(&entry_open) {
download_and_open(&ui_open, &entry_open); download_and_open(&ui_open, &entry_open);
} else { } else {
activate_entry(&ui_open, &entry_open); activate_entry(&ui_open, &entry_open);
@@ -610,14 +611,12 @@ pub(crate) fn entry_at(model: Option<&impl IsA<gio::ListModel>>, pos: u32) -> Op
Some(entry) Some(entry)
} }
/// Whether an entry's name marks it as a video, reusing the same classifier the /// Whether an entry can be opened through FUSE instead of fully materialized.
/// Photos page splits on — so the file browser and the gallery agree on what a pub(crate) fn is_streamable_media_entry(entry: &DirEntry) -> bool {
/// video is. drive_activation(&entry.name, entry.is_dir) == DriveActivation::MountedMedia
pub(crate) fn is_video_entry(entry: &DirEntry) -> bool {
!entry.is_dir && PhotoKind::classify(Some(&entry.name), None) == PhotoKind::Video
} }
/// 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 `<mountpoint>/<rel>` reads the file /// of the FUSE mount, so a player pointed at `<mountpoint>/<rel>` reads the file
/// through [`Core::read_range_remote`] — 4 MB blocks fetched on demand as it /// 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 /// 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<Ui>, entry: &DirEntry) { pub(crate) fn stream_entry(ui: &Rc<Ui>, entry: &DirEntry) {
let rel = entry_rel(ui, entry); let rel = entry_rel(ui, entry);
let mountpoint = ui.dirs.resolved_mountpoint(&ui.dirs.load_config()); 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 { let Some(path) = abs.to_str() else {
toast_error( toast_error(
ui, ui,
"Couldn't play video", "Couldn't play media",
"The file path isn't valid UTF-8.", "The file path isn't valid UTF-8.",
); );
return; return;
@@ -639,7 +638,7 @@ pub(crate) fn stream_entry(ui: &Rc<Ui>, entry: &DirEntry) {
play_external(path); 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. /// other files download-and-open.
pub(crate) fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) { pub(crate) fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
let rel = entry_rel(ui, entry); let rel = entry_rel(ui, entry);
@@ -651,8 +650,8 @@ pub(crate) fn activate_entry(ui: &Rc<Ui>, entry: &DirEntry) {
} }
*ui.browser.path.borrow_mut() = rel; *ui.browser.path.borrow_mut() = rel;
load_browser(ui); load_browser(ui);
} else if is_video_entry(entry) { } else if drive_activation(&entry.name, entry.is_dir) == DriveActivation::MountedMedia {
// A video streams rather than downloads: that is exactly the "play it, // Media streams rather than downloads: that is exactly the "play it,
// don't fetch the whole thing" behaviour this is for. // don't fetch the whole thing" behaviour this is for.
stream_entry(ui, entry); stream_entry(ui, entry);
} else { } else {
+254 -83
View File
@@ -1,11 +1,11 @@
//! `pdfs-prompt` — the launcher: one search field over both Proton Drive and the //! `pdfs-prompt` — the launcher: one search field over both Proton Drive and the
//! files on this machine, styled after Google Drive's search overlay. //! 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 //! Bind it to a system shortcut (e.g. in Hyprland) for a quick HUD search. The
//! a short-lived process: it must draw *now*, so nothing here blocks the main //! application is single-instance and keeps its window alive between summons,
//! loop. Every daemon round-trip runs on a worker thread and lands back through //! so repeat activations only reset and present the existing widget tree. Every
//! an async channel; the two searches (Drive and local) are fired in parallel and //! daemon round-trip runs on a worker thread and lands back through an async
//! each section renders as its own reply arrives. //! channel. Drive and local lookup share one request so a result set paints once.
use std::cell::{Cell, RefCell}; use std::cell::{Cell, RefCell};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
@@ -17,7 +17,12 @@ use adw::prelude::*;
use gtk4::{gio, glib}; use gtk4::{gio, glib};
use pdfs_core::config::AppDirs; 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"; const APP_ID: &str = "io.narl.proton-drive-linux-prompt";
@@ -60,6 +65,16 @@ impl Filter {
Filter::Media => !is_dir && is_media(name), 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 { fn extension(name: &str) -> String {
@@ -174,6 +189,13 @@ impl Hit {
matches!(self, Hit::Drive(h) if h.pinned) 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. /// 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. /// The bool keeps a Drive and a local hit at the same path distinct.
fn key(&self) -> (bool, String) { 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 /// 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. /// callbacks below capture a single `Rc` instead of a dozen clones each.
struct Ui { struct Ui {
@@ -195,6 +226,7 @@ struct Ui {
stack: gtk4::Stack, stack: gtk4::Stack,
scroller: gtk4::ScrolledWindow, scroller: gtk4::ScrolledWindow,
results: gtk4::Box, results: gtk4::Box,
search_section: Section,
drive_section: Section, drive_section: Section,
local_section: Section, local_section: Section,
placeholder: adw::StatusPage, placeholder: adw::StatusPage,
@@ -217,8 +249,7 @@ struct Ui {
/// Monotonic query id. A reply whose id is stale (the user typed again while /// 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. /// it was in flight) is dropped instead of overwriting fresher results.
query_id: Cell<u64>, query_id: Cell<u64>,
/// In-flight searches for the current query id, so the spinner stops only /// In-flight requests for the current query id.
/// once both sections have answered.
pending: Cell<u8>, pending: Cell<u8>,
/// True once a Drive open is running: the window is about to close, so /// True once a Drive open is running: the window is about to close, so
/// further input is ignored. /// further input is ignored.
@@ -229,6 +260,10 @@ struct Ui {
/// against the selected row once the fresh results settle, rather than being /// against the selected row once the fresh results settle, rather than being
/// dropped — so a plain "type, Enter" always opens something. /// dropped — so a plain "type, Enter" always opens something.
open_pending: Cell<bool>, open_pending: Cell<bool>,
/// 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<bool>,
/// The window, so a deferred open (above) can reach it from `render`. /// The window, so a deferred open (above) can reach it from `render`.
window: RefCell<Option<adw::ApplicationWindow>>, window: RefCell<Option<adw::ApplicationWindow>>,
} }
@@ -301,17 +336,33 @@ fn main() -> glib::ExitCode {
.init(); .init();
let app = adw::Application::builder().application_id(APP_ID).build(); 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_startup(|_| load_css());
app.connect_activate(build_window); let prompt: Rc<RefCell<Option<Rc<Ui>>>> = 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() app.run()
} }
fn build_window(app: &adw::Application) { fn build_window(app: &adw::Application) -> Option<Rc<Ui>> {
let dirs = match AppDirs::new() { let dirs = match AppDirs::new() {
Ok(d) => d, Ok(d) => d,
Err(e) => { Err(e) => {
tracing::error!("cannot resolve app dirs: {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); let results = gtk4::Box::new(gtk4::Orientation::Vertical, 0);
results.add_css_class("results"); 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 drive_section = Section::new("Proton Drive", "folder-remote-symbolic");
let local_section = Section::new("This computer", "drive-harddisk-symbolic"); let local_section = Section::new("This computer", "drive-harddisk-symbolic");
search_section.attach(&results);
drive_section.attach(&results); drive_section.attach(&results);
local_section.attach(&results); local_section.attach(&results);
@@ -430,6 +483,7 @@ fn build_window(app: &adw::Application) {
stack, stack,
scroller, scroller,
results, results,
search_section,
drive_section, drive_section,
local_section, local_section,
placeholder, placeholder,
@@ -446,6 +500,7 @@ fn build_window(app: &adw::Application) {
opening: Cell::new(false), opening: Cell::new(false),
indexing: Cell::new(false), indexing: Cell::new(false),
open_pending: Cell::new(false), open_pending: Cell::new(false),
suppress_entry_change: Cell::new(false),
window: RefCell::new(None), window: RefCell::new(None),
}); });
*ui.window.borrow_mut() = Some(window.clone()); *ui.window.borrow_mut() = Some(window.clone());
@@ -477,15 +532,17 @@ fn build_window(app: &adw::Application) {
(&ui.local_section.list, true), (&ui.local_section.list, true),
] { ] {
let ui_row = ui.clone(); let ui_row = ui.clone();
let window_row = window.clone();
list.connect_row_activated(move |_, row| { list.connect_row_activated(move |_, row| {
let offset = if is_local { ui_row.drive_count() } else { 0 }; 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 ui_key = ui.clone();
let window_key = window.clone();
let keys = gtk4::EventControllerKey::new(); let keys = gtk4::EventControllerKey::new();
keys.connect_key_pressed(move |_, key, _, state| { keys.connect_key_pressed(move |_, key, _, state| {
if ui_key.opening.get() { 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 // Spotlight-style: first Escape clears a non-empty query, a
// second (or an already-empty box) dismisses the launcher. // second (or an already-empty box) dismisses the launcher.
if ui_key.entry.text().is_empty() { if ui_key.entry.text().is_empty() {
window_key.close(); ui_key.dismiss();
} else { } else {
ui_key.entry.set_text(""); 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 // focused entry is never reached. Enter with focus in the list is covered by
// `row_activated` above. // `row_activated` above.
let ui_activate = ui.clone(); let ui_activate = ui.clone();
let window_activate = window.clone();
entry.connect_activate(move |_| { entry.connect_activate(move |_| {
if ui_activate.opening.get() { if ui_activate.opening.get() {
return; return;
} }
// Fall back to the top row when nothing is explicitly selected (e.g. // Fall back to the top row when nothing is explicitly selected (e.g.
// results haven't rendered yet); `open` guards the rest. // 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<RefCell<Option<glib::SourceId>>> = Rc::new(RefCell::new(None)); let debounce: Rc<RefCell<Option<glib::SourceId>>> = Rc::new(RefCell::new(None));
let ui_changed = ui.clone(); let ui_changed = ui.clone();
entry.connect_changed(move |entry| { entry.connect_changed(move |entry| {
if ui_changed.suppress_entry_change.get() {
return;
}
// A fresh keystroke supersedes any Enter that was waiting on results: // A fresh keystroke supersedes any Enter that was waiting on results:
// the user is still refining the query, not asking to open yet. // the user is still refining the query, not asking to open yet.
ui_changed.open_pending.set(false); ui_changed.open_pending.set(false);
@@ -564,22 +643,59 @@ fn build_window(app: &adw::Application) {
let ui_retry = ui.clone(); let ui_retry = ui.clone();
retry.connect_clicked(move |_| ui_retry.connect()); retry.connect_clicked(move |_| ui_retry.connect());
// A launcher is modal by convention: losing focus dismisses it. // Keep the widget tree alive when the compositor asks the window to close.
window.connect_is_active_notify(|w| { // This also avoids running GTK's destruction path while async replies still
if !w.is_active() { // hold references to prompt widgets.
w.close(); let ui_close = ui.clone();
} window.connect_close_request(move |_| {
ui_close.dismiss();
glib::Propagation::Stop
}); });
let entry_map = entry.clone(); let entry_map = entry.clone();
window.connect_map(move |_| { window.connect_map(move |_| {
entry_map.grab_focus(); entry_map.grab_focus();
}); });
ui.connect(); Some(ui)
window.present();
} }
impl 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<Self>) {
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) /// 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. /// files. Runs off-thread: a dead daemon must not freeze the first frame.
fn connect(self: &Rc<Self>) { fn connect(self: &Rc<Self>) {
@@ -640,6 +756,7 @@ impl Ui {
modified: 0, modified: 0,
pinned: true, pinned: true,
uid: pin.uid, uid: pin.uid,
score: 0,
}) })
.collect(), .collect(),
_ => Vec::new(), _ => Vec::new(),
@@ -650,32 +767,41 @@ impl Ui {
}); });
} }
/// Fire both searches for `query` in parallel. An empty query falls back to /// Search Drive and local metadata in one daemon round-trip. An empty query
/// the suggestions view. /// falls back to the suggestions view.
fn search(self: &Rc<Self>, query: &str) { fn search(self: &Rc<Self>, query: &str) {
if query.is_empty() { if query.is_empty() {
self.load_suggestions(); self.load_suggestions();
return; return;
} }
let id = self.begin_query(2); let id = self.begin_query(1);
let search = spawn_request(
let drive = spawn_request(
self.socket.clone(), self.socket.clone(),
Request::Search { Request::SearchV2 {
query: query.to_string(), query: query.to_string(),
limit: SEARCH_LIMIT, limit: SEARCH_LIMIT,
filters: SearchFilters {
sources: vec![SearchSource::Drive, SearchSource::Local],
kind: self.filter.get().search_kind(),
},
}, },
); );
let ui = self.clone(); let ui = self.clone();
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
let reply = drive.recv().await; let reply = search.recv().await;
if !ui.finish_query(id) { if !ui.finish_query(id) {
return; return;
} }
match reply { match reply {
Ok(Ok(Response::SearchResults { hits })) => { Ok(Ok(Response::SearchResultsV2 {
*ui.drive_hits.borrow_mut() = hits; 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(); ui.render();
} }
// Transport failure: the daemon is gone. Don't paint "No // Transport failure: the daemon is gone. Don't paint "No
@@ -683,32 +809,6 @@ impl Ui {
Ok(Err(_)) | Err(_) => ui.go_offline(), Ok(Err(_)) | Err(_) => ui.go_offline(),
_ => { _ => {
ui.drive_hits.borrow_mut().clear(); 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.local_hits.borrow_mut().clear();
ui.render(); ui.render();
} }
@@ -751,7 +851,12 @@ impl Ui {
for (chip_filter, chip) in self.chips.borrow().iter() { for (chip_filter, chip) in self.chips.borrow().iter() {
chip.set_active(*chip_filter == filter); 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<Self>, backwards: bool) { fn cycle_filter(self: &Rc<Self>, backwards: bool) {
@@ -776,9 +881,8 @@ impl Ui {
.count() .count()
} }
/// Rebuild both sections from the last replies under the active chip, then /// Rebuild suggestions or the globally-ranked search section, preserving the
/// restore the cursor to the top. Every state change funnels through here, so /// selected identity when a result survives the update.
/// the list, the placeholder, and the footer can never disagree.
fn render(self: &Rc<Self>) { fn render(self: &Rc<Self>) {
let filter = self.filter.get(); let filter = self.filter.get();
@@ -808,17 +912,24 @@ impl Ui {
let query = self.entry.text().trim().to_string(); let query = self.entry.text().trim().to_string();
let searching = !query.is_empty(); 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 total = drive.len() + local.len();
let mut visible = drive; let mut visible = drive;
visible.extend(local); 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. // 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)); 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. // so it lands on the final list, not a half-populated one.
if self.open_pending.get() && self.pending.get() == 0 { if self.open_pending.get() && self.pending.get() == 0 {
self.open_pending.set(false); self.open_pending.set(false);
if let (Some(index), Some(window)) = (self.cursor.get(), self.window.borrow().clone()) { if let Some(index) = self.cursor.get() {
self.open(index, &window); self.open(index);
} }
} }
} }
@@ -892,6 +1003,19 @@ impl Ui {
/// section owns it, clear the other section's selection, and scroll it into /// section owns it, clear the other section's selection, and scroll it into
/// view. Focus stays in the entry so typing never breaks. /// view. Focus stays in the entry so typing never breaks.
fn select(self: &Rc<Self>, index: usize) { fn select(self: &Rc<Self>, 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 drive_count = self.drive_count();
let (list, other, local_index) = if index < 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 /// 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, /// by the daemon (`OpenFile`), which can take a moment — the window stays up,
/// with the spinner running, until the path comes back. /// with the spinner running, until the path comes back.
fn open(self: &Rc<Self>, index: usize, window: &adw::ApplicationWindow) { fn open(self: &Rc<Self>, index: usize) {
if self.opening.get() { if self.opening.get() {
return; return;
} }
@@ -957,15 +1081,30 @@ impl Ui {
match hit { match hit {
Hit::Local(local) => { Hit::Local(local) => {
xdg_open(Path::new(&local.path)); xdg_open(Path::new(&local.path));
window.close(); self.dismiss();
}
// 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();
} }
Hit::Drive(drive) => { 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.opening.set(true);
self.spinner.set_visible(true); self.spinner.set_visible(true);
self.spinner.start(); self.spinner.start();
@@ -978,7 +1117,6 @@ impl Ui {
}, },
); );
let ui = self.clone(); let ui = self.clone();
let window = window.clone();
glib::spawn_future_local(async move { glib::spawn_future_local(async move {
let reply = rx.recv().await; let reply = rx.recv().await;
ui.spinner.stop(); ui.spinner.stop();
@@ -987,7 +1125,7 @@ impl Ui {
match reply { match reply {
Ok(Ok(Response::FilePath { path })) => { Ok(Ok(Response::FilePath { path })) => {
xdg_open(Path::new(&path)); xdg_open(Path::new(&path));
window.close(); ui.dismiss();
} }
Ok(Ok(Response::Error { message, .. })) => { Ok(Ok(Response::Error { message, .. })) => {
ui.hint.set_label(&format!("Could not open: {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"));
}
}
+60
View File
@@ -76,6 +76,37 @@ assert got == expected
print(hashlib.sha256(got).hexdigest()) print(hashlib.sha256(got).hexdigest())
PY 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" step "rename, move, and replacement semantics"
mkdir -- "$primary/dir-a" "$primary/dir-b" mkdir -- "$primary/dir-a" "$primary/dir-b"
printf 'source' > "$primary/source" 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" ]] || { [[ "$(sha256sum "$secondary/binary.dat" | cut -d' ' -f1)" == "$expected" ]] || {
echo "content mismatch on $secondary" >&2; exit 1 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 done
fi fi