From 3b0b391da4818995023ad1d81bd02a8c4d4bf5f9 Mon Sep 17 00:00:00 2001 From: Emmanuel Leblond Date: Wed, 4 Mar 2026 00:16:25 +0100 Subject: [PATCH 1/4] Implement `WorspaceOps::search()` & `WorspaceHistoryOps::search()` --- Cargo.lock | 1 + libparsec/crates/client/Cargo.toml | 1 + libparsec/crates/client/src/lib.rs | 1 + libparsec/crates/client/src/search.rs | 139 +++++++ libparsec/crates/client/src/workspace/mod.rs | 13 +- .../client/src/workspace/transactions/mod.rs | 2 + .../src/workspace/transactions/search.rs | 102 +++++ .../client/src/workspace_history/mod.rs | 15 +- .../src/workspace_history/transactions/mod.rs | 2 + .../workspace_history/transactions/search.rs | 112 +++++ libparsec/crates/client/tests/unit/search.rs | 106 +++++ .../crates/client/tests/unit/workspace/mod.rs | 1 + .../client/tests/unit/workspace/search.rs | 170 ++++++++ .../tests/unit/workspace_history/mod.rs | 1 + .../tests/unit/workspace_history/search.rs | 389 ++++++++++++++++++ .../tests/unit/workspace_history/utils.rs | 8 +- 16 files changed, 1055 insertions(+), 8 deletions(-) create mode 100644 libparsec/crates/client/src/search.rs create mode 100644 libparsec/crates/client/src/workspace/transactions/search.rs create mode 100644 libparsec/crates/client/src/workspace_history/transactions/search.rs create mode 100644 libparsec/crates/client/tests/unit/search.rs create mode 100644 libparsec/crates/client/tests/unit/workspace/search.rs create mode 100644 libparsec/crates/client/tests/unit/workspace_history/search.rs diff --git a/Cargo.lock b/Cargo.lock index fc172379725..71b3112a0e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2356,6 +2356,7 @@ dependencies = [ "smallvec", "thiserror 2.0.18", "tokio", + "unicode-normalization", "zeroize", ] diff --git a/libparsec/crates/client/Cargo.toml b/libparsec/crates/client/Cargo.toml index d4d32a9b76d..6acceceea89 100644 --- a/libparsec/crates/client/Cargo.toml +++ b/libparsec/crates/client/Cargo.toml @@ -25,6 +25,7 @@ libparsec_types = { workspace = true } paste = { workspace = true } log = { workspace = true } +unicode-normalization = { workspace = true, features = ["std"] } blahaj = { workspace = true } smallvec = { workspace = true } thiserror = { workspace = true } diff --git a/libparsec/crates/client/src/lib.rs b/libparsec/crates/client/src/lib.rs index 84da5ce0a0d..7fc1452b0af 100644 --- a/libparsec/crates/client/src/lib.rs +++ b/libparsec/crates/client/src/lib.rs @@ -9,6 +9,7 @@ mod device; mod event_bus; mod invite; mod monitors; +mod search; mod server_fetch; mod user; mod utils; diff --git a/libparsec/crates/client/src/search.rs b/libparsec/crates/client/src/search.rs new file mode 100644 index 00000000000..540061411c5 --- /dev/null +++ b/libparsec/crates/client/src/search.rs @@ -0,0 +1,139 @@ +// Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS + +use libparsec_types::prelude::*; +use unicode_normalization::UnicodeNormalization; + +/// The algorithm is a word-based substring matcher: +/// - The query is first NFC-normalized (since that's what `FsPath` does) +/// - The query is then split on unicode whitespace into *needles*. +/// - Every needle must appear as a contiguous, case-insensitive substring +/// somewhere in the path. This is the accept/reject gate. +/// - The score rewards patterns that indicate a strong match: +/// - full word match (no unicode alphabetic/numeric chars on either side) (+3 per needle) +/// - first match at position 0 (+5) +/// - match lands in the name component (+8 per needle) +/// +/// Examples (path = `/foo/bar.txt`): +/// "foo bar" → matches ("foo" ✓, "bar" ✓) +/// "bar foo" → matches ("bar" ✓, "foo" ✓) +/// "foo/bar" → matches (single needle, substring of path) +/// "f b" → matches ("f" ✓, "b" ✓) +/// "fb" → no match (not a contiguous substring) +/// "foobar" → no match (not a contiguous substring) +pub(crate) struct FuzzyMatch { + /// Pre-processed needles: each is already NFC-normalized and lowercased. + needles: Vec>, +} + +impl FuzzyMatch { + pub(crate) fn new(query: &str) -> Self { + // NFC-normalize the query so that decomposed characters (e.g. NFD "é") + // match the NFC-normalized form stored in FsPath. + let query_nfc: String = query.nfc().collect(); + let needles = query_nfc + .split_whitespace() + .map(|needle| needle.to_lowercase().chars().collect()) + .collect(); + Self { needles } + } + + /// Returns `Some((score, char_positions))` when `self` matches `path`, + /// `None` otherwise. + /// + /// `char_positions` contains the *character* (not byte) indices into + /// `path.to_string()` of each matched character, in ascending order. + pub(crate) fn matches(&self, path: &FsPath) -> Option<(u32, Vec)> { + if self.needles.is_empty() { + return Some((0, vec![])); + } + + let path_str = path.to_string(); + let path_chars: Vec = path_str.chars().collect(); + let path_lower_chars: Vec = path_str.to_lowercase().chars().collect(); + + let name_char_start = name_char_start(path, &path_str); + let mut all_positions: Vec = Vec::new(); + let mut score = 0u32; + + for needle_lower in &self.needles { + let nlen = needle_lower.len(); + + // Find the first case-insensitive substring occurrence. + let found = (0..=path_lower_chars.len().saturating_sub(nlen)) + .find(|&start| path_lower_chars[start..start + nlen] == needle_lower[..]); + + match found { + Some(start) => { + for i in 0..nlen { + all_positions.push((start + i) as u32); + } + + // Full word match bonus: neither the character immediately before + // the match nor the one immediately after is alphabetic or numeric. + let before_is_word = start > 0 && { + let c = path_chars[start - 1]; + c.is_alphabetic() || c.is_numeric() + }; + let after_is_word = start + nlen < path_chars.len() && { + let c = path_chars[start + nlen]; + c.is_alphabetic() || c.is_numeric() + }; + if !before_is_word && !after_is_word { + score += 3; + } + + // Leading name match bonus: needle starts at the first character + // of the name component (e.g. "rep" in "/report.txt"). + if start as u32 == name_char_start { + score += 5; + } + } + None => return None, + } + } + + all_positions.sort(); + all_positions.dedup(); + + // First match at position 0 bonus (e.g. needle contains '/'). + if all_positions.first().copied() == Some(0) { + score += 5; + } + + score += score_positions(&all_positions, name_char_start); + + Some((score, all_positions)) + } +} + +/// Returns the character index at which the last path component (the name) +/// starts inside `path_str`, or `u32::MAX` when there is no name (root). +fn name_char_start(path: &FsPath, path_str: &str) -> u32 { + match path.name() { + None => u32::MAX, // root has no name + Some(name) => { + let name_bytes = name.as_ref().len(); + let path_bytes = path_str.len(); + // The name occupies the last `name_bytes` bytes, preceded by '/'. + // Convert the byte offset to a character offset. + let byte_start = path_bytes.saturating_sub(name_bytes); + path_str[..byte_start].chars().count() as u32 + } + } +} + +/// Per-character score contribution: base point plus name-component bonus. +fn score_positions(positions: &[u32], name_char_start: u32) -> u32 { + positions.iter().fold(0u32, |acc, &pos| { + let mut pts = 1; // base: one point per matched character + if pos >= name_char_start { + pts += 8; // name-component bonus + } + acc + pts + }) +} + +#[cfg(test)] +#[path = "../tests/unit/search.rs"] +#[allow(clippy::unwrap_used)] +mod tests; diff --git a/libparsec/crates/client/src/workspace/mod.rs b/libparsec/crates/client/src/workspace/mod.rs index 93c429a2f07..178e44a5bcc 100644 --- a/libparsec/crates/client/src/workspace/mod.rs +++ b/libparsec/crates/client/src/workspace/mod.rs @@ -26,8 +26,9 @@ pub use transactions::{ WorkspaceFdResizeError, WorkspaceFdStatError, WorkspaceFdWriteError, WorkspaceGetNeedInboundSyncEntriesError, WorkspaceGetNeedOutboundSyncEntriesError, WorkspaceIsFileContentLocalError, WorkspaceMoveEntryError, WorkspaceOpenFileError, - WorkspaceOpenFolderReaderError, WorkspaceRemoveEntryError, WorkspaceStatEntryError, - WorkspaceStatFolderChildrenError, WorkspaceSyncError, WorkspaceWatchEntryOneShotError, + WorkspaceOpenFolderReaderError, WorkspaceRemoveEntryError, WorkspaceSearch, + WorkspaceSearchMatch, WorkspaceStatEntryError, WorkspaceStatFolderChildrenError, + WorkspaceSyncError, WorkspaceWatchEntryOneShotError, }; use self::{store::FileUpdater, transactions::FdWriteStrategy}; @@ -388,6 +389,14 @@ impl WorkspaceOps { transactions::stat_folder_children_by_id(self, entry_id).await } + /// Fuzzy-search all entries in the workspace by name/path. + /// + /// The workspace tree is crawled in a breadth-first way (i.e. a subdirectory of + /// depth n will only be searched after all item of depth n-1). + pub fn search(self: Arc, path: FsPath, query: &str) -> WorkspaceSearch { + transactions::search(self, path, query) + } + pub async fn move_entry( &self, src: FsPath, diff --git a/libparsec/crates/client/src/workspace/transactions/mod.rs b/libparsec/crates/client/src/workspace/transactions/mod.rs index 3bde1fd84ff..49fefc4a605 100644 --- a/libparsec/crates/client/src/workspace/transactions/mod.rs +++ b/libparsec/crates/client/src/workspace/transactions/mod.rs @@ -15,6 +15,7 @@ mod open_file; mod outbound_sync; mod read_folder; mod remove_entry; +mod search; mod stat_entry; mod watch_entry; @@ -33,5 +34,6 @@ pub use open_file::*; pub use outbound_sync::*; pub use read_folder::*; pub use remove_entry::*; +pub use search::*; pub use stat_entry::*; pub use watch_entry::*; diff --git a/libparsec/crates/client/src/workspace/transactions/search.rs b/libparsec/crates/client/src/workspace/transactions/search.rs new file mode 100644 index 00000000000..af233b0b74c --- /dev/null +++ b/libparsec/crates/client/src/workspace/transactions/search.rs @@ -0,0 +1,102 @@ +// Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS + +use std::{collections::VecDeque, sync::Arc}; + +use libparsec_platform_async::channel; +use libparsec_types::prelude::*; + +use crate::{ + search::FuzzyMatch, + workspace::{transactions::EntryStat, WorkspaceOps, WorkspaceStatFolderChildrenError}, +}; + +/// A single entry whose path fuzzy-matched the search query. +#[derive(Debug, Clone)] +pub struct WorkspaceSearchMatch { + pub path: FsPath, + pub stat: EntryStat, + /// Relevance score — higher means a better match. + pub score: u32, + /// Indices into the *characters* of `path.to_string()` that were matched, + /// in ascending order. Useful for rendering highlights in a UI. + pub match_positions: Vec, +} + +/// An in-progress fuzzy search, note the underlaying task is aborted once this +/// struct is dropped. +#[derive(Debug)] +pub struct WorkspaceSearch { + abort_handle: libparsec_platform_async::AbortHandle, + pub results: channel::Receiver, +} + +impl Drop for WorkspaceSearch { + fn drop(&mut self) { + self.abort_handle.abort(); + } +} + +/// The traversal visits directories breadth-first, so shallower (typically +/// more relevant) entries appear in the channel first. Only entries that are +/// strict descendants of `root` are visited; `root` itself is not emitted. +/// +/// Note dropping the returned [`WorkspaceSearch`] aborts the underlying search task. +pub fn search(ops: Arc, root: FsPath, query: &str) -> WorkspaceSearch { + // Use a rendez-vous channel (i.e. with a 0 capacity) to only search if the + // caller is actually interested in the result (i.e. fetches it). + let (tx, rx) = channel::bounded(0); + + let fuzzy_match = FuzzyMatch::new(query); + + let join_handle = libparsec_platform_async::spawn(search_task(ops, root, fuzzy_match, tx)); + + WorkspaceSearch { + abort_handle: join_handle.abort_handle(), + results: rx, + } +} + +async fn search_task( + ops: Arc, + root: FsPath, + fuzzy_match: FuzzyMatch, + tx: channel::Sender, +) { + // Note the traversal visits directories breadth-first, so shallower (typically + // more relevant) entries appear in the channel first. + + let mut to_search_subdirs: VecDeque = VecDeque::new(); + to_search_subdirs.push_back(root); + + while let Some(dir_path) = to_search_subdirs.pop_front() { + let children = match ops.stat_folder_children(&dir_path).await { + Ok(c) => c, + // The workspace was stopped, no point continuing. + Err(WorkspaceStatFolderChildrenError::Stopped) => return, + // Anything else (permission denied, bad manifest, …), skip this + // directory and keep going so the rest of the tree is still visited. + Err(_) => continue, + }; + + for (name, stat) in children { + let child_path = dir_path.join(name); + + if let Some((score, match_positions)) = fuzzy_match.matches(&child_path) { + let m = WorkspaceSearchMatch { + path: child_path.clone(), + stat: stat.clone(), + score, + match_positions, + }; + if tx.send_async(m).await.is_err() { + // A send error means the receiver was dropped + return; + } + } + + if matches!(stat, EntryStat::Folder { .. }) { + to_search_subdirs.push_back(child_path); + } + } + } +} diff --git a/libparsec/crates/client/src/workspace_history/mod.rs b/libparsec/crates/client/src/workspace_history/mod.rs index 4a1532e6f05..38634607a16 100644 --- a/libparsec/crates/client/src/workspace_history/mod.rs +++ b/libparsec/crates/client/src/workspace_history/mod.rs @@ -8,8 +8,9 @@ pub use transactions::{ WorkspaceHistoryEntryStat, WorkspaceHistoryFdCloseError, WorkspaceHistoryFdReadError, WorkspaceHistoryFdStatError, WorkspaceHistoryFileStat, WorkspaceHistoryFolderReader, WorkspaceHistoryFolderReaderStatEntryError, WorkspaceHistoryFolderReaderStatNextOutcome, - WorkspaceHistoryOpenFileError, WorkspaceHistoryOpenFolderReaderError, - WorkspaceHistoryStatEntryError, WorkspaceHistoryStatFolderChildrenError, + WorkspaceHistoryOpenFileError, WorkspaceHistoryOpenFolderReaderError, WorkspaceHistorySearch, + WorkspaceHistorySearchMatch, WorkspaceHistoryStatEntryError, + WorkspaceHistoryStatFolderChildrenError, }; use std::{ @@ -287,6 +288,16 @@ impl WorkspaceHistoryOps { transactions::stat_folder_children_by_id(self, self.timestamp_of_interest(), entry_id).await } + /// Fuzzy-search all entries in the workspace history by name/path. + /// + /// The workspace tree is crawled in a breadth-first way (i.e. a subdirectory of + /// depth n will only be searched after all item of depth n-1). + /// + /// The timestamp of interest is captured at the time this method is called. + pub fn search(self: Arc, path: FsPath, query: &str) -> WorkspaceHistorySearch { + transactions::search(self, path, query) + } + pub async fn open_file( &self, path: FsPath, diff --git a/libparsec/crates/client/src/workspace_history/transactions/mod.rs b/libparsec/crates/client/src/workspace_history/transactions/mod.rs index 7e794b7a641..b9310033357 100644 --- a/libparsec/crates/client/src/workspace_history/transactions/mod.rs +++ b/libparsec/crates/client/src/workspace_history/transactions/mod.rs @@ -5,6 +5,7 @@ mod fd_read; mod fd_stat; mod open_file; mod read_folder; +mod search; mod stat_entry; pub use fd_close::*; @@ -12,6 +13,7 @@ pub use fd_read::*; pub use fd_stat::*; pub use open_file::*; pub use read_folder::*; +pub use search::*; pub use stat_entry::*; // pub use transactions::{ diff --git a/libparsec/crates/client/src/workspace_history/transactions/search.rs b/libparsec/crates/client/src/workspace_history/transactions/search.rs new file mode 100644 index 00000000000..8920ca2e4e1 --- /dev/null +++ b/libparsec/crates/client/src/workspace_history/transactions/search.rs @@ -0,0 +1,112 @@ +// Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS + +use std::{collections::VecDeque, sync::Arc}; + +use libparsec_platform_async::channel; +use libparsec_types::prelude::*; + +use crate::{ + search::FuzzyMatch, + workspace_history::{ + transactions::{ + stat_folder_children, WorkspaceHistoryEntryStat, + WorkspaceHistoryStatFolderChildrenError, + }, + WorkspaceHistoryOps, + }, +}; + +/// A single entry whose path fuzzy-matched the search query. +#[derive(Debug, Clone)] +pub struct WorkspaceHistorySearchMatch { + pub path: FsPath, + pub stat: WorkspaceHistoryEntryStat, + /// Relevance score — higher means a better match. + pub score: u32, + /// Indices into the *characters* of `path.to_string()` that were matched, + /// in ascending order. Useful for rendering highlights in a UI. + pub match_positions: Vec, +} + +/// An in-progress fuzzy search, note the underlaying task is aborted once this +/// struct is dropped. +#[derive(Debug)] +pub struct WorkspaceHistorySearch { + abort_handle: libparsec_platform_async::AbortHandle, + pub results: channel::Receiver, +} + +impl Drop for WorkspaceHistorySearch { + fn drop(&mut self) { + self.abort_handle.abort(); + } +} + +/// The traversal visits directories breadth-first, so shallower (typically +/// more relevant) entries appear in the channel first. Only entries that are +/// strict descendants of `root` are visited; `root` itself is not emitted. +/// +/// The timestamp of interest is captured at the time this function is called. +/// +/// Note dropping the returned [`WorkspaceHistorySearch`] aborts the underlying search task. +pub fn search(ops: Arc, root: FsPath, query: &str) -> WorkspaceHistorySearch { + let at = ops.timestamp_of_interest(); + // Use a rendez-vous channel (i.e. with a 0 capacity) to only search if the + // caller is actually interested in the result (i.e. fetches it). + let (tx, rx) = channel::bounded(0); + + let fuzzy_match = FuzzyMatch::new(query); + + let join_handle = libparsec_platform_async::spawn(search_task(ops, at, root, fuzzy_match, tx)); + + WorkspaceHistorySearch { + abort_handle: join_handle.abort_handle(), + results: rx, + } +} + +async fn search_task( + ops: Arc, + at: DateTime, + root: FsPath, + fuzzy_match: FuzzyMatch, + tx: channel::Sender, +) { + // Note the traversal visits directories breadth-first, so shallower (typically + // more relevant) entries appear in the channel first. + + let mut to_search_subdirs: VecDeque = VecDeque::new(); + to_search_subdirs.push_back(root); + + while let Some(dir_path) = to_search_subdirs.pop_front() { + let children = match stat_folder_children(&ops, at, &dir_path).await { + Ok(c) => c, + // The component was stopped, no point continuing. + Err(WorkspaceHistoryStatFolderChildrenError::Stopped) => return, + // Anything else (offline, permission denied, bad manifest, …), skip this + // directory and keep going so the rest of the tree is still visited. + Err(_) => continue, + }; + + for (name, stat) in children { + let child_path = dir_path.join(name); + + if let Some((score, match_positions)) = fuzzy_match.matches(&child_path) { + let m = WorkspaceHistorySearchMatch { + path: child_path.clone(), + stat: stat.clone(), + score, + match_positions, + }; + if tx.send_async(m).await.is_err() { + // A send error means the receiver was dropped + return; + } + } + + if matches!(stat, WorkspaceHistoryEntryStat::Folder { .. }) { + to_search_subdirs.push_back(child_path); + } + } + } +} diff --git a/libparsec/crates/client/tests/unit/search.rs b/libparsec/crates/client/tests/unit/search.rs new file mode 100644 index 00000000000..8575fb893bf --- /dev/null +++ b/libparsec/crates/client/tests/unit/search.rs @@ -0,0 +1,106 @@ +// Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS + +use libparsec_types::prelude::*; + +use super::FuzzyMatch; + +fn path(s: &str) -> FsPath { + s.parse().unwrap() +} + +fn fuzzy_match(query: &str, path: &FsPath) -> Option<(u32, Vec)> { + FuzzyMatch::new(query).matches(path) +} + +#[test] +fn empty_query_matches_everything() { + assert!(fuzzy_match("", &path("/")).is_some()); + assert!(fuzzy_match("", &path("/a/b/c.txt")).is_some()); +} + +#[test] +fn no_substring_does_not_match() { + assert!(fuzzy_match("xyz", &path("/abc")).is_none()); + assert!(fuzzy_match("az", &path("/abc")).is_none()); +} + +#[test] +fn simple_substring_matches() { + assert!(fuzzy_match("abc", &path("/abc")).is_some()); + assert!(fuzzy_match("rep", &path("/docs/report.txt")).is_some()); +} + +#[test] +fn word_based_multi_needle() { + let p = path("/foo/bar.txt"); + // Each needle is independently a substring → match + assert!(fuzzy_match("foo bar", &p).is_some()); + assert!(fuzzy_match("bar foo", &p).is_some()); + assert!(fuzzy_match("f b", &p).is_some()); + // Single needle containing '/' is a substring of the path + assert!(fuzzy_match("foo/bar", &p).is_some()); + // Not a contiguous substring → no match + assert!(fuzzy_match("fb", &p).is_none()); + assert!(fuzzy_match("foobar", &p).is_none()); +} + +#[test] +fn case_insensitive_match() { + assert!(fuzzy_match("REP", &path("/docs/report.txt")).is_some()); + assert!(fuzzy_match("Rep", &path("/docs/report.txt")).is_some()); +} + +/// An NFD-encoded query (e.g. "e\u{301}" for "é") must match an NFC path +/// because the query is NFC-normalized before matching. +#[test] +fn nfd_query_matches_nfc_path() { + // "café" in NFC: c a f \u{e9} + // "café" in NFD: c a f e \u{301} (e + combining acute accent) + let nfc_path = path("/docs/café.txt"); + let nfd_query = "cafe\u{301}"; // NFD form of "café" + assert!(fuzzy_match(nfd_query, &nfc_path).is_some()); +} + +#[test] +fn match_positions_are_ascending() { + let (_, positions) = fuzzy_match("rep", &path("/docs/report.txt")).unwrap(); + assert!(positions.windows(2).all(|w| w[0] < w[1])); +} + +#[test] +fn match_positions_cover_query_chars() { + let p = path("/docs/report.txt"); + let path_str = p.to_string(); + let path_chars: Vec = path_str.chars().collect(); + + let (_, positions) = fuzzy_match("rep", &p).unwrap(); + let matched: String = positions.iter().map(|&i| path_chars[i as usize]).collect(); + assert_eq!(matched.to_lowercase(), "rep"); +} + +#[test] +fn leading_match_scores_higher() { + let (leading, _) = fuzzy_match("rep", &path("/report.txt")).unwrap(); + let (other, _) = fuzzy_match("epo", &path("/report.txt")).unwrap(); + assert!( + leading > other, + "leading={leading} should be > other={other}" + ); +} + +#[test] +fn name_match_scores_higher_than_parent_match() { + let (in_name, _) = fuzzy_match("rep", &path("/other/report.txt")).unwrap(); + let (in_parent, _) = fuzzy_match("rep", &path("/reports/other.txt")).unwrap(); + assert!( + in_name > in_parent, + "in_name={in_name} should be > in_parent={in_parent}" + ); +} + +#[test] +fn full_word_match_scores_higher_than_partial() { + let (full, _) = fuzzy_match("report", &path("/docs/report.txt")).unwrap(); + let (part, _) = fuzzy_match("rep", &path("/docs/report.txt")).unwrap(); + assert!(full > part, "full={full} should be > part={part}"); +} diff --git a/libparsec/crates/client/tests/unit/workspace/mod.rs b/libparsec/crates/client/tests/unit/workspace/mod.rs index 0ff469d97f2..6db62a0838f 100644 --- a/libparsec/crates/client/tests/unit/workspace/mod.rs +++ b/libparsec/crates/client/tests/unit/workspace/mod.rs @@ -28,6 +28,7 @@ mod read_folder; mod remove_entry; mod resolve_path; mod retrieve_path_from_id; +mod search; mod stat_entry; mod store; mod utils; diff --git a/libparsec/crates/client/tests/unit/workspace/search.rs b/libparsec/crates/client/tests/unit/workspace/search.rs new file mode 100644 index 00000000000..b9de49d733a --- /dev/null +++ b/libparsec/crates/client/tests/unit/workspace/search.rs @@ -0,0 +1,170 @@ +// Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS + +use std::sync::Arc; + +use libparsec_platform_async::channel::{self, RecvError}; +use libparsec_tests_fixtures::prelude::*; +use libparsec_types::prelude::*; + +use super::utils::workspace_ops_factory; +use crate::workspace::{EntryStat, WorkspaceSearchMatch}; + +async fn drain_sorted(rx: &channel::Receiver) -> Vec { + let mut paths = Vec::new(); + while let Ok(m) = rx.recv_async().await { + paths.push(m.path.to_string()); + } + paths.sort(); + paths +} + +async fn drain_matches(rx: &channel::Receiver) -> Vec { + let mut results = Vec::new(); + while let Ok(m) = rx.recv_async().await { + results.push(m); + } + results +} + +// The `minimal_client_ready` testbed workspace layout: +// +// /bar.txt (file) +// /foo/ (folder) +// /foo/egg.txt (file) +// /foo/spam/ (folder) + +#[parsec_test(testbed = "minimal_client_ready")] +async fn empty_query_returns_all(env: &TestbedEnv) { + let wksp1_id: VlobID = *env.template.get_stuff("wksp1_id"); + let alice = env.local_device("alice@dev1"); + let ops = + Arc::new(workspace_ops_factory(&env.discriminant_dir, &alice, wksp1_id.to_owned()).await); + + let root: FsPath = "/".parse().unwrap(); + let search = Arc::clone(&ops).search(root.clone(), ""); + let paths = drain_sorted(&search.results).await; + + p_assert_eq!(paths, ["/bar.txt", "/foo", "/foo/egg.txt", "/foo/spam"]); +} + +#[parsec_test(testbed = "minimal_client_ready")] +async fn match_by_name(env: &TestbedEnv) { + let wksp1_id: VlobID = *env.template.get_stuff("wksp1_id"); + let alice = env.local_device("alice@dev1"); + let ops = + Arc::new(workspace_ops_factory(&env.discriminant_dir, &alice, wksp1_id.to_owned()).await); + + // "bar" is a subsequence of "/bar.txt" but does not appear in any other path + let root: FsPath = "/".parse().unwrap(); + let search = Arc::clone(&ops).search(root.clone(), "bar"); + p_assert_eq!(drain_sorted(&search.results).await, ["/bar.txt"]); +} + +#[parsec_test(testbed = "minimal_client_ready")] +async fn no_match_returns_empty(env: &TestbedEnv) { + let wksp1_id: VlobID = *env.template.get_stuff("wksp1_id"); + let alice = env.local_device("alice@dev1"); + let ops = + Arc::new(workspace_ops_factory(&env.discriminant_dir, &alice, wksp1_id.to_owned()).await); + + let root: FsPath = "/".parse().unwrap(); + let search = Arc::clone(&ops).search(root.clone(), "zzzz"); + let paths = drain_sorted(&search.results).await; + + assert!(paths.is_empty(), "expected no results, got {paths:?}"); +} + +#[parsec_test(testbed = "minimal_client_ready")] +async fn bfs_order_shallow_before_deep(env: &TestbedEnv) { + let wksp1_id: VlobID = *env.template.get_stuff("wksp1_id"); + let alice = env.local_device("alice@dev1"); + let ops = + Arc::new(workspace_ops_factory(&env.discriminant_dir, &alice, wksp1_id.to_owned()).await); + + let root: FsPath = "/".parse().unwrap(); + let search = Arc::clone(&ops).search(root.clone(), ""); + let results = drain_matches(&search.results).await; + + p_assert_eq!(results.len(), 4); + + // The first two results must be root-level entries (depth 1: bar.txt, foo) + let first_two_depths: Vec = results[..2].iter().map(|m| m.path.parts().len()).collect(); + p_assert_eq!(first_two_depths, [1, 1]); + + // The last two results must be entries nested inside /foo (depth 2) + let last_two_depths: Vec = results[2..].iter().map(|m| m.path.parts().len()).collect(); + p_assert_eq!(last_two_depths, [2, 2]); +} + +#[parsec_test(testbed = "minimal_client_ready")] +async fn stat_type_matches_entry_kind(env: &TestbedEnv) { + let wksp1_id: VlobID = *env.template.get_stuff("wksp1_id"); + let alice = env.local_device("alice@dev1"); + let ops = + Arc::new(workspace_ops_factory(&env.discriminant_dir, &alice, wksp1_id.to_owned()).await); + + let root: FsPath = "/".parse().unwrap(); + let search = Arc::clone(&ops).search(root.clone(), ""); + let results = drain_matches(&search.results).await; + + let find = |path_str: &str| { + results + .iter() + .find(|m| m.path.to_string() == path_str) + .unwrap_or_else(|| panic!("no result for {path_str}")) + }; + + p_assert_matches!(find("/bar.txt").stat, EntryStat::File { .. }); + p_assert_matches!(find("/foo").stat, EntryStat::Folder { .. }); + p_assert_matches!(find("/foo/egg.txt").stat, EntryStat::File { .. }); + p_assert_matches!(find("/foo/spam").stat, EntryStat::Folder { .. }); +} + +#[parsec_test(testbed = "minimal_client_ready")] +async fn subdirectory_scope(env: &TestbedEnv) { + let wksp1_id: VlobID = *env.template.get_stuff("wksp1_id"); + let alice = env.local_device("alice@dev1"); + let ops = + Arc::new(workspace_ops_factory(&env.discriminant_dir, &alice, wksp1_id.to_owned()).await); + + let foo: FsPath = "/foo".parse().unwrap(); + + // Empty query from /foo: should see only /foo's descendants, not /bar.txt + // or /foo itself. + let search = Arc::clone(&ops).search(foo.clone(), ""); + p_assert_eq!( + drain_sorted(&search.results).await, + ["/foo/egg.txt", "/foo/spam"] + ); + + // A name-based query scoped to /foo. + let search = Arc::clone(&ops).search(foo.clone(), "egg"); + p_assert_eq!(drain_sorted(&search.results).await, ["/foo/egg.txt"]); + + // A query that would match /bar.txt from root returns nothing from /foo. + let search = Arc::clone(&ops).search(foo.clone(), "bar"); + assert!( + drain_sorted(&search.results).await.is_empty(), + "bar.txt must not appear when searching under /foo" + ); +} + +#[parsec_test(testbed = "minimal_client_ready")] +async fn abort_on_drop(env: &TestbedEnv) { + let wksp1_id: VlobID = *env.template.get_stuff("wksp1_id"); + let alice = env.local_device("alice@dev1"); + let ops = + Arc::new(workspace_ops_factory(&env.discriminant_dir, &alice, wksp1_id.to_owned()).await); + + let root: FsPath = "/".parse().unwrap(); + let search = Arc::clone(&ops).search(root.clone(), ""); + + let rx = search.results.clone(); + drop(search); // aborts the task + + // Since it is a rendez-vous channel, `rx` doesn't contain anything and is + // closed once the task has been aborted. Use recv_async so the executor can + // actually run and cancel the task (synchronous recv would deadlock in a + // single-threaded executor). + p_assert_matches!(rx.recv_async().await, Err(RecvError::Disconnected)); +} diff --git a/libparsec/crates/client/tests/unit/workspace_history/mod.rs b/libparsec/crates/client/tests/unit/workspace_history/mod.rs index f43a4cc2361..4c532f2cd5f 100644 --- a/libparsec/crates/client/tests/unit/workspace_history/mod.rs +++ b/libparsec/crates/client/tests/unit/workspace_history/mod.rs @@ -8,6 +8,7 @@ mod open_file_by_id; // Realm export database support is not available on web. #[cfg(not(target_arch = "wasm32"))] mod realm_export_access_sequester_decryptor; +mod search; mod start; mod stat_entry; mod stat_entry_by_id; diff --git a/libparsec/crates/client/tests/unit/workspace_history/search.rs b/libparsec/crates/client/tests/unit/workspace_history/search.rs new file mode 100644 index 00000000000..4bb2ffedae6 --- /dev/null +++ b/libparsec/crates/client/tests/unit/workspace_history/search.rs @@ -0,0 +1,389 @@ +// Parsec Cloud (https://parsec.cloud) Copyright (c) BUSL-1.1 2016-present Scille SAS + +use std::sync::Arc; + +use libparsec_client_connection::{ + test_register_sequence_of_send_hooks, test_send_hook_vlob_read_batch, +}; +use libparsec_platform_async::channel::{self, RecvError}; +use libparsec_tests_fixtures::prelude::*; +use libparsec_types::prelude::*; + +use super::utils::{DataAccessStrategy, StartWorkspaceHistoryOpsError}; +use crate::workspace_history::{WorkspaceHistoryEntryStat, WorkspaceHistorySearchMatch}; + +async fn drain_sorted(rx: &channel::Receiver) -> Vec { + let mut paths = Vec::new(); + while let Ok(m) = rx.recv_async().await { + paths.push(m.path.to_string()); + } + paths.sort(); + paths +} + +async fn drain_matches( + rx: &channel::Receiver, +) -> Vec { + let mut results = Vec::new(); + while let Ok(m) = rx.recv_async().await { + results.push(m); + } + results +} + +// The `workspace_history` testbed workspace layout at +// `wksp1_foo_v2_children_available_timestamp`: +// +// /bar.txt (file) +// /foo/ (folder) +// /foo/egg.txt (file) +// /foo/spam/ (folder, empty) + +/// Register the server hooks required for a full BFS traversal from the +/// workspace root. The BFS visits two levels: +/// 1. children of `/` → bar.txt and foo manifests +/// 2. children of `/foo` → egg.txt and spam manifests +/// 3. children of `/foo/spam` → none (empty) +/// +/// Note: the `/` manifest is always in cache after `start_workspace_history_ops_at`; +/// the `/foo` manifest is cached after level 1, so it is not re-fetched at level 2. +macro_rules! register_bfs_from_root_hooks { + ($env:expr, $timestamp:expr, $wksp1_id:expr, $bar_txt_id:expr, $foo_id:expr, $egg_txt_id:expr, $spam_id:expr) => { + test_register_sequence_of_send_hooks!( + &$env.discriminant_dir, + // Level 1: children of `/` (order is non-deterministic due to HashMap) + test_send_hook_vlob_read_batch!($env, at: $timestamp, $wksp1_id, allowed: [$bar_txt_id, $foo_id]), + test_send_hook_vlob_read_batch!($env, at: $timestamp, $wksp1_id, allowed: [$bar_txt_id, $foo_id]), + // Level 2: children of `/foo` (foo manifest is already cached from level 1) + test_send_hook_vlob_read_batch!($env, at: $timestamp, $wksp1_id, allowed: [$egg_txt_id, $spam_id]), + test_send_hook_vlob_read_batch!($env, at: $timestamp, $wksp1_id, allowed: [$egg_txt_id, $spam_id]), + // Level 3: `/foo/spam` is empty — no hooks needed + ) + }; +} + +/// Register the server hooks required for a BFS traversal starting from `/foo`. +/// Here the `/foo` manifest is not yet cached, so `resolve_path` fetches it first. +macro_rules! register_bfs_from_foo_hooks { + ($env:expr, $timestamp:expr, $wksp1_id:expr, $foo_id:expr, $egg_txt_id:expr, $spam_id:expr) => { + test_register_sequence_of_send_hooks!( + &$env.discriminant_dir, + // resolve_path("/foo"): workspace root in cache → fetch `/foo` manifest + test_send_hook_vlob_read_batch!($env, at: $timestamp, $wksp1_id, $foo_id), + // Children of `/foo` (order is non-deterministic) + test_send_hook_vlob_read_batch!($env, at: $timestamp, $wksp1_id, allowed: [$egg_txt_id, $spam_id]), + test_send_hook_vlob_read_batch!($env, at: $timestamp, $wksp1_id, allowed: [$egg_txt_id, $spam_id]), + // `/foo/spam` is empty — no hooks needed + ) + }; +} + +#[parsec_test(testbed = "workspace_history")] +async fn empty_query_returns_all( + #[values(DataAccessStrategy::Server, DataAccessStrategy::RealmExport)] + strategy: DataAccessStrategy, + env: &TestbedEnv, +) { + let wksp1_id: VlobID = *env.template.get_stuff("wksp1_id"); + let wksp1_bar_txt_id: VlobID = *env.template.get_stuff("wksp1_bar_txt_id"); + let wksp1_foo_id: VlobID = *env.template.get_stuff("wksp1_foo_id"); + let wksp1_foo_egg_txt_id: VlobID = *env.template.get_stuff("wksp1_foo_egg_txt_id"); + let wksp1_foo_spam_id: VlobID = *env.template.get_stuff("wksp1_foo_spam_id"); + let timestamp: DateTime = *env + .template + .get_stuff("wksp1_foo_v2_children_available_timestamp"); + + let ops = match strategy + .start_workspace_history_ops_at(env, timestamp) + .await + { + Ok(ops) => ops, + Err(StartWorkspaceHistoryOpsError::RealmExportNotSupportedOnWeb) => return, + }; + + if matches!(strategy, DataAccessStrategy::Server) { + register_bfs_from_root_hooks!( + env, + timestamp, + wksp1_id, + wksp1_bar_txt_id, + wksp1_foo_id, + wksp1_foo_egg_txt_id, + wksp1_foo_spam_id + ); + } + + let search = Arc::clone(&ops).search("/".parse().unwrap(), ""); + let paths = drain_sorted(&search.results).await; + + p_assert_eq!(paths, ["/bar.txt", "/foo", "/foo/egg.txt", "/foo/spam"]); +} + +#[parsec_test(testbed = "workspace_history")] +async fn match_by_name( + #[values(DataAccessStrategy::Server, DataAccessStrategy::RealmExport)] + strategy: DataAccessStrategy, + env: &TestbedEnv, +) { + let wksp1_id: VlobID = *env.template.get_stuff("wksp1_id"); + let wksp1_bar_txt_id: VlobID = *env.template.get_stuff("wksp1_bar_txt_id"); + let wksp1_foo_id: VlobID = *env.template.get_stuff("wksp1_foo_id"); + let wksp1_foo_egg_txt_id: VlobID = *env.template.get_stuff("wksp1_foo_egg_txt_id"); + let wksp1_foo_spam_id: VlobID = *env.template.get_stuff("wksp1_foo_spam_id"); + let timestamp: DateTime = *env + .template + .get_stuff("wksp1_foo_v2_children_available_timestamp"); + + let ops = match strategy + .start_workspace_history_ops_at(env, timestamp) + .await + { + Ok(ops) => ops, + Err(StartWorkspaceHistoryOpsError::RealmExportNotSupportedOnWeb) => return, + }; + + if matches!(strategy, DataAccessStrategy::Server) { + register_bfs_from_root_hooks!( + env, + timestamp, + wksp1_id, + wksp1_bar_txt_id, + wksp1_foo_id, + wksp1_foo_egg_txt_id, + wksp1_foo_spam_id + ); + } + + // "bar" only matches "/bar.txt" + let search = Arc::clone(&ops).search("/".parse().unwrap(), "bar"); + p_assert_eq!(drain_sorted(&search.results).await, ["/bar.txt"]); +} + +#[parsec_test(testbed = "workspace_history")] +async fn no_match_returns_empty( + #[values(DataAccessStrategy::Server, DataAccessStrategy::RealmExport)] + strategy: DataAccessStrategy, + env: &TestbedEnv, +) { + let wksp1_id: VlobID = *env.template.get_stuff("wksp1_id"); + let wksp1_bar_txt_id: VlobID = *env.template.get_stuff("wksp1_bar_txt_id"); + let wksp1_foo_id: VlobID = *env.template.get_stuff("wksp1_foo_id"); + let wksp1_foo_egg_txt_id: VlobID = *env.template.get_stuff("wksp1_foo_egg_txt_id"); + let wksp1_foo_spam_id: VlobID = *env.template.get_stuff("wksp1_foo_spam_id"); + let timestamp: DateTime = *env + .template + .get_stuff("wksp1_foo_v2_children_available_timestamp"); + + let ops = match strategy + .start_workspace_history_ops_at(env, timestamp) + .await + { + Ok(ops) => ops, + Err(StartWorkspaceHistoryOpsError::RealmExportNotSupportedOnWeb) => return, + }; + + if matches!(strategy, DataAccessStrategy::Server) { + register_bfs_from_root_hooks!( + env, + timestamp, + wksp1_id, + wksp1_bar_txt_id, + wksp1_foo_id, + wksp1_foo_egg_txt_id, + wksp1_foo_spam_id + ); + } + + let search = Arc::clone(&ops).search("/".parse().unwrap(), "zzzz"); + let paths = drain_sorted(&search.results).await; + + assert!(paths.is_empty(), "expected no results, got {paths:?}"); +} + +#[parsec_test(testbed = "workspace_history")] +async fn bfs_order_shallow_before_deep( + #[values(DataAccessStrategy::Server, DataAccessStrategy::RealmExport)] + strategy: DataAccessStrategy, + env: &TestbedEnv, +) { + let wksp1_id: VlobID = *env.template.get_stuff("wksp1_id"); + let wksp1_bar_txt_id: VlobID = *env.template.get_stuff("wksp1_bar_txt_id"); + let wksp1_foo_id: VlobID = *env.template.get_stuff("wksp1_foo_id"); + let wksp1_foo_egg_txt_id: VlobID = *env.template.get_stuff("wksp1_foo_egg_txt_id"); + let wksp1_foo_spam_id: VlobID = *env.template.get_stuff("wksp1_foo_spam_id"); + let timestamp: DateTime = *env + .template + .get_stuff("wksp1_foo_v2_children_available_timestamp"); + + let ops = match strategy + .start_workspace_history_ops_at(env, timestamp) + .await + { + Ok(ops) => ops, + Err(StartWorkspaceHistoryOpsError::RealmExportNotSupportedOnWeb) => return, + }; + + if matches!(strategy, DataAccessStrategy::Server) { + register_bfs_from_root_hooks!( + env, + timestamp, + wksp1_id, + wksp1_bar_txt_id, + wksp1_foo_id, + wksp1_foo_egg_txt_id, + wksp1_foo_spam_id + ); + } + + let search = Arc::clone(&ops).search("/".parse().unwrap(), ""); + let results = drain_matches(&search.results).await; + + p_assert_eq!(results.len(), 4); + + // The first two results must be root-level entries (depth 1: bar.txt, foo) + let first_two_depths: Vec = results[..2].iter().map(|m| m.path.parts().len()).collect(); + p_assert_eq!(first_two_depths, [1, 1]); + + // The last two results must be entries nested inside /foo (depth 2) + let last_two_depths: Vec = results[2..].iter().map(|m| m.path.parts().len()).collect(); + p_assert_eq!(last_two_depths, [2, 2]); +} + +#[parsec_test(testbed = "workspace_history")] +async fn stat_type_matches_entry_kind( + #[values(DataAccessStrategy::Server, DataAccessStrategy::RealmExport)] + strategy: DataAccessStrategy, + env: &TestbedEnv, +) { + let wksp1_id: VlobID = *env.template.get_stuff("wksp1_id"); + let wksp1_bar_txt_id: VlobID = *env.template.get_stuff("wksp1_bar_txt_id"); + let wksp1_foo_id: VlobID = *env.template.get_stuff("wksp1_foo_id"); + let wksp1_foo_egg_txt_id: VlobID = *env.template.get_stuff("wksp1_foo_egg_txt_id"); + let wksp1_foo_spam_id: VlobID = *env.template.get_stuff("wksp1_foo_spam_id"); + let timestamp: DateTime = *env + .template + .get_stuff("wksp1_foo_v2_children_available_timestamp"); + + let ops = match strategy + .start_workspace_history_ops_at(env, timestamp) + .await + { + Ok(ops) => ops, + Err(StartWorkspaceHistoryOpsError::RealmExportNotSupportedOnWeb) => return, + }; + + if matches!(strategy, DataAccessStrategy::Server) { + register_bfs_from_root_hooks!( + env, + timestamp, + wksp1_id, + wksp1_bar_txt_id, + wksp1_foo_id, + wksp1_foo_egg_txt_id, + wksp1_foo_spam_id + ); + } + + let search = Arc::clone(&ops).search("/".parse().unwrap(), ""); + let results = drain_matches(&search.results).await; + + let find = |path_str: &str| { + results + .iter() + .find(|m| m.path.to_string() == path_str) + .unwrap_or_else(|| panic!("no result for {path_str}")) + }; + + p_assert_matches!( + find("/bar.txt").stat, + WorkspaceHistoryEntryStat::File { .. } + ); + p_assert_matches!(find("/foo").stat, WorkspaceHistoryEntryStat::Folder { .. }); + p_assert_matches!( + find("/foo/egg.txt").stat, + WorkspaceHistoryEntryStat::File { .. } + ); + p_assert_matches!( + find("/foo/spam").stat, + WorkspaceHistoryEntryStat::Folder { .. } + ); +} + +#[parsec_test(testbed = "workspace_history")] +async fn subdirectory_scope( + #[values(DataAccessStrategy::Server, DataAccessStrategy::RealmExport)] + strategy: DataAccessStrategy, + env: &TestbedEnv, +) { + let wksp1_id: VlobID = *env.template.get_stuff("wksp1_id"); + let wksp1_foo_id: VlobID = *env.template.get_stuff("wksp1_foo_id"); + let wksp1_foo_egg_txt_id: VlobID = *env.template.get_stuff("wksp1_foo_egg_txt_id"); + let wksp1_foo_spam_id: VlobID = *env.template.get_stuff("wksp1_foo_spam_id"); + let timestamp: DateTime = *env + .template + .get_stuff("wksp1_foo_v2_children_available_timestamp"); + + let ops = match strategy + .start_workspace_history_ops_at(env, timestamp) + .await + { + Ok(ops) => ops, + Err(StartWorkspaceHistoryOpsError::RealmExportNotSupportedOnWeb) => return, + }; + + // Empty query from /foo: should see only /foo's descendants, not /bar.txt or /foo itself. + // Only the first search needs hooks for the server strategy; subsequent searches + // on the same ops instance find the manifests already in cache. + if matches!(strategy, DataAccessStrategy::Server) { + register_bfs_from_foo_hooks!( + env, + timestamp, + wksp1_id, + wksp1_foo_id, + wksp1_foo_egg_txt_id, + wksp1_foo_spam_id + ); + } + let search = Arc::clone(&ops).search("/foo".parse().unwrap(), ""); + p_assert_eq!( + drain_sorted(&search.results).await, + ["/foo/egg.txt", "/foo/spam"] + ); + + // A name-based query scoped to /foo (manifests are now cached). + let search = Arc::clone(&ops).search("/foo".parse().unwrap(), "egg"); + p_assert_eq!(drain_sorted(&search.results).await, ["/foo/egg.txt"]); + + // A query that would match /bar.txt from root returns nothing from /foo. + let search = Arc::clone(&ops).search("/foo".parse().unwrap(), "bar"); + assert!( + drain_sorted(&search.results).await.is_empty(), + "bar.txt must not appear when searching under /foo" + ); +} + +#[parsec_test(testbed = "workspace_history")] +async fn abort_on_drop(env: &TestbedEnv) { + let timestamp: DateTime = *env + .template + .get_stuff("wksp1_foo_v2_children_available_timestamp"); + + let ops = match DataAccessStrategy::RealmExport + .start_workspace_history_ops_at(env, timestamp) + .await + { + Ok(ops) => ops, + Err(StartWorkspaceHistoryOpsError::RealmExportNotSupportedOnWeb) => return, + }; + + let search = Arc::clone(&ops).search("/".parse().unwrap(), ""); + + let rx = search.results.clone(); + drop(search); // aborts the task + + // Since it is a rendez-vous channel, `rx` doesn't contain anything and is + // closed once the task has been aborted. Use recv_async so the executor can + // actually run and cancel the task (synchronous recv would deadlock in a + // single-threaded executor). + p_assert_matches!(rx.recv_async().await, Err(RecvError::Disconnected)); +} diff --git a/libparsec/crates/client/tests/unit/workspace_history/utils.rs b/libparsec/crates/client/tests/unit/workspace_history/utils.rs index bc657fe5282..b2635cd09ce 100644 --- a/libparsec/crates/client/tests/unit/workspace_history/utils.rs +++ b/libparsec/crates/client/tests/unit/workspace_history/utils.rs @@ -154,7 +154,7 @@ pub enum DataAccessStrategy { } pub struct WorkspaceHistoryOpsWithMaybeTmpPath { - ops: WorkspaceHistoryOps, + ops: Arc, /// Must be kept here since the temporary path is removed on drop _tmp_path: Option, } @@ -166,7 +166,7 @@ impl WorkspaceHistoryOpsWithMaybeTmpPath { } impl std::ops::Deref for WorkspaceHistoryOpsWithMaybeTmpPath { - type Target = WorkspaceHistoryOps; + type Target = Arc; fn deref(&self) -> &Self::Target { &self.ops } @@ -224,7 +224,7 @@ impl DataAccessStrategy { ) .await; WorkspaceHistoryOpsWithMaybeTmpPath { - ops, + ops: Arc::new(ops), _tmp_path: Some(tmp_path), } } @@ -235,7 +235,7 @@ impl DataAccessStrategy { let ops = workspace_history_ops_with_server_access_factory(env, &alice, wksp1_id).await; WorkspaceHistoryOpsWithMaybeTmpPath { - ops, + ops: Arc::new(ops), _tmp_path: None, } } From fb1554f4bb6b2d273657280da5f334de048892fc Mon Sep 17 00:00:00 2001 From: Emmanuel Leblond Date: Wed, 4 Mar 2026 00:31:18 +0100 Subject: [PATCH 2/4] Expose `workspace(_history)_search` in GUI bindings --- bindings/generator/api/workspace.py | 43 ++++++++ bindings/generator/api/workspace_history.py | 43 ++++++++ libparsec/src/client.rs | 7 ++ libparsec/src/handle.rs | 13 +++ libparsec/src/workspace.rs | 93 ++++++++++++++++- libparsec/src/workspace_history.rs | 108 +++++++++++++++++++- 6 files changed, 301 insertions(+), 6 deletions(-) diff --git a/bindings/generator/api/workspace.py b/bindings/generator/api/workspace.py index 11c4e7e1603..96859c87f3f 100644 --- a/bindings/generator/api/workspace.py +++ b/bindings/generator/api/workspace.py @@ -6,6 +6,7 @@ ParsecWorkspacePathAddrAndRedirectionURL, ) from .common import ( + U32, U64, DateTime, DeviceID, @@ -774,3 +775,45 @@ async def workspace_decrypt_path_addr( link: Ref[ParsecWorkspacePathAddr], ) -> Result[FsPath, WorkspaceDecryptPathAddrError]: raise NotImplementedError + + +class WorkspaceSearchMatch(Structure): + path: FsPath + stat: EntryStat + score: U32 + match_positions: list[U32] + + +class WorkspaceSearchError(ErrorVariant): + class Internal: + pass + + +async def workspace_search( + workspace: Handle, + path: FsPath, + query: Ref[str], +) -> Result[Handle, WorkspaceSearchError]: + raise NotImplementedError + + +class WorkspaceSearchGetNextError(ErrorVariant): + class Internal: + pass + + +async def workspace_search_get_next( + search: Handle, +) -> Result[WorkspaceSearchMatch | None, WorkspaceSearchGetNextError]: + raise NotImplementedError + + +class WorkspaceSearchCloseError(ErrorVariant): + class Internal: + pass + + +def workspace_search_close( + search: Handle, +) -> Result[None, WorkspaceSearchCloseError]: + raise NotImplementedError diff --git a/bindings/generator/api/workspace_history.py b/bindings/generator/api/workspace_history.py index 6628b315b65..b44f9d8995e 100644 --- a/bindings/generator/api/workspace_history.py +++ b/bindings/generator/api/workspace_history.py @@ -2,6 +2,7 @@ from .client import ClientConfig, DeviceAccessStrategy from .common import ( + U32, U64, DateTime, DeviceID, @@ -408,3 +409,45 @@ async def workspace_history_fd_stat( fd: FileDescriptor, ) -> Result[WorkspaceHistoryFileStat, WorkspaceHistoryFdStatError]: raise NotImplementedError + + +class WorkspaceHistorySearchMatch(Structure): + path: FsPath + stat: WorkspaceHistoryEntryStat + score: U32 + match_positions: list[U32] + + +class WorkspaceHistorySearchError(ErrorVariant): + class Internal: + pass + + +async def workspace_history_search( + workspace_history: Handle, + path: FsPath, + query: Ref[str], +) -> Result[Handle, WorkspaceHistorySearchError]: + raise NotImplementedError + + +class WorkspaceHistorySearchGetNextError(ErrorVariant): + class Internal: + pass + + +async def workspace_history_search_get_next( + search: Handle, +) -> Result[WorkspaceHistorySearchMatch | None, WorkspaceHistorySearchGetNextError]: + raise NotImplementedError + + +class WorkspaceHistorySearchCloseError(ErrorVariant): + class Internal: + pass + + +def workspace_history_search_close( + search: Handle, +) -> Result[None, WorkspaceHistorySearchCloseError]: + raise NotImplementedError diff --git a/libparsec/src/client.rs b/libparsec/src/client.rs index 52d7c302bcd..b89b481a98e 100644 --- a/libparsec/src/client.rs +++ b/libparsec/src/client.rs @@ -341,6 +341,13 @@ pub async fn client_stop(client: Handle) -> Result<(), ClientStopError> { client: Some(x_client), .. } + | HandleItem::WorkspaceSearch { + client: x_client, .. + } + | HandleItem::WorkspaceHistorySearch { + client: Some(x_client), + .. + } | HandleItem::Mountpoint { client: x_client, .. } if *x_client == client_handle => FilterCloseHandle::Close, diff --git a/libparsec/src/handle.rs b/libparsec/src/handle.rs index 9939bcab69c..7fd3fc60a63 100644 --- a/libparsec/src/handle.rs +++ b/libparsec/src/handle.rs @@ -71,6 +71,19 @@ pub(crate) enum HandleItem { workspace_history_ops: Arc, }, + WorkspaceSearch { + client: Handle, + workspace: Handle, + search: libparsec_client::workspace::WorkspaceSearch, + }, + + WorkspaceHistorySearch { + /// `None` if the workspace history uses a realm export database + client: Option, + workspace_history: Handle, + search: libparsec_client::workspace_history::WorkspaceHistorySearch, + }, + Account(Arc), UserGreetInitial(libparsec_client::UserGreetInitialCtx), diff --git a/libparsec/src/workspace.rs b/libparsec/src/workspace.rs index bbf776f3aa6..07c2f582e65 100644 --- a/libparsec/src/workspace.rs +++ b/libparsec/src/workspace.rs @@ -8,7 +8,8 @@ pub use libparsec_client::workspace::{ WorkspaceFdFlushError, WorkspaceFdReadError, WorkspaceFdResizeError, WorkspaceFdStatError, WorkspaceFdWriteError, WorkspaceGeneratePathAddrError, WorkspaceIsFileContentLocalError, WorkspaceMoveEntryError, WorkspaceOpenFileError, WorkspaceRemoveEntryError, - WorkspaceStatEntryError, WorkspaceStatFolderChildrenError, WorkspaceWatchEntryOneShotError, + WorkspaceSearchMatch, WorkspaceStatEntryError, WorkspaceStatFolderChildrenError, + WorkspaceWatchEntryOneShotError, }; use libparsec_platform_async::event::{Event, EventListener}; use libparsec_types::prelude::*; @@ -177,7 +178,11 @@ pub async fn workspace_stop(workspace: Handle) -> Result<(), WorkspaceStopError> loop { let mut maybe_wait = None; filter_close_handles(client_handle, |x| match x { - HandleItem::Mountpoint { + HandleItem::WorkspaceSearch { + workspace: x_workspace, + .. + } + | HandleItem::Mountpoint { workspace: x_workspace, .. } if *x_workspace == workspace_handle => FilterCloseHandle::Close, @@ -746,3 +751,87 @@ pub async fn workspace_decrypt_path_addr( workspace.decrypt_path_addr(link).await } + +/* + * Workspace search + */ + +#[derive(Debug, thiserror::Error)] +pub enum WorkspaceSearchError { + #[error(transparent)] + Internal(#[from] anyhow::Error), +} + +/// Start a fuzzy search over the workspace and return a handle. +/// +/// Matched entries can be retrieved one at a time with [`workspace_search_get_next`]. +/// Call [`workspace_search_close`] to abort the search and release the handle. +pub async fn workspace_search( + workspace: Handle, + path: FsPath, + query: &str, +) -> Result { + let workspace_handle = workspace; + let (client_handle, workspace) = borrow_from_handle(workspace_handle, |x| match x { + HandleItem::Workspace { + client, + workspace_ops, + .. + } => Some((*client, workspace_ops.clone())), + _ => None, + })?; + + let search = workspace.search(path, query); + + let search_handle = crate::handle::register_handle(HandleItem::WorkspaceSearch { + client: client_handle, + workspace: workspace_handle, + search, + }); + + Ok(search_handle) +} + +#[derive(Debug, thiserror::Error)] +pub enum WorkspaceSearchGetNextError { + #[error(transparent)] + Internal(#[from] anyhow::Error), +} + +/// Retrieve the next fuzzy-search result. +/// +/// Returns `Ok(Some(…))` when a match is available, `Ok(None)` when the +/// search has finished (all entries visited) or has been aborted via +/// [`workspace_search_close`]. +pub async fn workspace_search_get_next( + search: Handle, +) -> Result, WorkspaceSearchGetNextError> { + let search_receiver = borrow_from_handle(search, |x| match x { + HandleItem::WorkspaceSearch { search, .. } => Some(search.results.clone()), + _ => None, + })?; + + match search_receiver.recv_async().await { + Ok(item) => Ok(Some(item)), + // No more items (seach completed or has been aborted) + Err(_) => Ok(None), + } +} + +#[derive(Debug, thiserror::Error)] +pub enum WorkspaceSearchCloseError { + #[error(transparent)] + Internal(#[from] anyhow::Error), +} + +pub fn workspace_search_close(search: Handle) -> Result<(), WorkspaceSearchCloseError> { + let search = take_and_close_handle(search, |x| match *x { + HandleItem::WorkspaceSearch { search, .. } => Ok(search), + _ => Err(x), + })?; + + // Drop aborts the underlying task + drop(search); + + Ok(()) +} diff --git a/libparsec/src/workspace_history.rs b/libparsec/src/workspace_history.rs index 27304084058..215c4500e11 100644 --- a/libparsec/src/workspace_history.rs +++ b/libparsec/src/workspace_history.rs @@ -6,8 +6,8 @@ pub use libparsec_client::{ workspace_history::{ WorkspaceHistoryEntryStat, WorkspaceHistoryFdCloseError, WorkspaceHistoryFdReadError, WorkspaceHistoryFdStatError, WorkspaceHistoryFileStat, WorkspaceHistoryOpenFileError, - WorkspaceHistorySetTimestampOfInterestError, WorkspaceHistoryStatEntryError, - WorkspaceHistoryStatFolderChildrenError, + WorkspaceHistorySearchMatch, WorkspaceHistorySetTimestampOfInterestError, + WorkspaceHistoryStatEntryError, WorkspaceHistoryStatFolderChildrenError, }, WorkspaceHistoryOpsStartError as WorkspaceHistoryStartError, }; @@ -16,7 +16,8 @@ use libparsec_types::prelude::*; use crate::{ device::DeviceAccessStrategy, handle::{ - borrow_from_handle, register_handle_with_init, take_and_close_handle, Handle, HandleItem, + borrow_from_handle, filter_close_handles, register_handle_with_init, take_and_close_handle, + FilterCloseHandle, Handle, HandleItem, }, }; @@ -218,7 +219,19 @@ pub fn workspace_history_stop( } _ => Err(x), }) - .map_err(|err| err.into()) + .map_err(WorkspaceHistoryInternalOnlyError::Internal)?; + + // Cleanup any open search handles related to this workspace history + // (dropping a WorkspaceHistorySearch aborts its underlying task). + filter_close_handles(workspace_history, |x| match x { + HandleItem::WorkspaceHistorySearch { + workspace_history: x_workspace_history, + .. + } if *x_workspace_history == workspace_history => FilterCloseHandle::Close, + _ => FilterCloseHandle::Keep, + }); + + Ok(()) } /* @@ -353,3 +366,90 @@ pub async fn workspace_history_fd_stat( workspace_history.fd_stat(fd).await } + +/* + * Workspace history search + */ + +#[derive(Debug, thiserror::Error)] +pub enum WorkspaceHistorySearchError { + #[error(transparent)] + Internal(#[from] anyhow::Error), +} + +/// Start a fuzzy search over the workspace history and return a handle. +/// +/// Matched entries can be retrieved one at a time with [`workspace_history_search_get_next`]. +/// Call [`workspace_history_search_close`] to abort the search and release the handle. +pub async fn workspace_history_search( + workspace_history: Handle, + path: FsPath, + query: &str, +) -> Result { + let workspace_history_handle = workspace_history; + + let (client, workspace_history_ops) = borrow_from_handle(workspace_history, |x| match x { + HandleItem::WorkspaceHistory { + client, + workspace_history_ops, + .. + } => Some((client.to_owned(), workspace_history_ops.clone())), + _ => None, + })?; + + let search = workspace_history_ops.search(path, query); + + let search_handle = crate::handle::register_handle(HandleItem::WorkspaceHistorySearch { + client, + workspace_history: workspace_history_handle, + search, + }); + + Ok(search_handle) +} + +#[derive(Debug, thiserror::Error)] +pub enum WorkspaceHistorySearchGetNextError { + #[error(transparent)] + Internal(#[from] anyhow::Error), +} + +/// Retrieve the next fuzzy-search result. +/// +/// Returns `Ok(Some(…))` when a match is available, `Ok(None)` when the +/// search has finished (all entries visited) or has been aborted via +/// [`workspace_history_search_close`]. +pub async fn workspace_history_search_get_next( + search: Handle, +) -> Result, WorkspaceHistorySearchGetNextError> { + let search_receiver = borrow_from_handle(search, |x| match x { + HandleItem::WorkspaceHistorySearch { search, .. } => Some(search.results.clone()), + _ => None, + })?; + + match search_receiver.recv_async().await { + Ok(item) => Ok(Some(item)), + // No more items (search completed or has been aborted) + Err(_) => Ok(None), + } +} + +#[derive(Debug, thiserror::Error)] +pub enum WorkspaceHistorySearchCloseError { + #[error(transparent)] + Internal(#[from] anyhow::Error), +} + +pub fn workspace_history_search_close( + search: Handle, +) -> Result<(), WorkspaceHistorySearchCloseError> { + let search = take_and_close_handle(search, |x| match *x { + HandleItem::WorkspaceHistorySearch { search, .. } => Ok(search), + _ => Err(x), + })?; + + // Drop aborts the underlying task + drop(search); + + Ok(()) +} From 55c1ed21b8ec4473e6a6a883d54f82c2e5514bec Mon Sep 17 00:00:00 2001 From: Emmanuel Leblond Date: Wed, 4 Mar 2026 00:31:45 +0100 Subject: [PATCH 3/4] Re-generate electron&web bindings --- bindings/electron/src/index.d.ts | 92 +++ bindings/electron/src/meths.rs | 646 ++++++++++++++++++++ bindings/web/src/meths.rs | 506 +++++++++++++++ client/src/plugins/libparsec/definitions.ts | 108 ++++ 4 files changed, 1352 insertions(+) diff --git a/bindings/electron/src/index.d.ts b/bindings/electron/src/index.d.ts index cc69b0fa5d2..37e163a86a1 100644 --- a/bindings/electron/src/index.d.ts +++ b/bindings/electron/src/index.d.ts @@ -501,6 +501,14 @@ export interface WorkspaceHistoryFileStat { } +export interface WorkspaceHistorySearchMatch { + path: string + stat: WorkspaceHistoryEntryStat + score: number + matchPositions: Array +} + + export interface WorkspaceInfo { id: string currentName: string @@ -510,6 +518,14 @@ export interface WorkspaceInfo { } +export interface WorkspaceSearchMatch { + path: string + stat: EntryStat + score: number + matchPositions: Array +} + + export interface WorkspaceUserAccessInfo { userId: string humanHandle: HumanHandle @@ -4289,6 +4305,33 @@ export type WorkspaceHistoryRealmExportDecryptor = | WorkspaceHistoryRealmExportDecryptorUser +// WorkspaceHistorySearchCloseError +export interface WorkspaceHistorySearchCloseErrorInternal { + tag: "WorkspaceHistorySearchCloseErrorInternal" + error: string +} +export type WorkspaceHistorySearchCloseError = + | WorkspaceHistorySearchCloseErrorInternal + + +// WorkspaceHistorySearchError +export interface WorkspaceHistorySearchErrorInternal { + tag: "WorkspaceHistorySearchErrorInternal" + error: string +} +export type WorkspaceHistorySearchError = + | WorkspaceHistorySearchErrorInternal + + +// WorkspaceHistorySearchGetNextError +export interface WorkspaceHistorySearchGetNextErrorInternal { + tag: "WorkspaceHistorySearchGetNextErrorInternal" + error: string +} +export type WorkspaceHistorySearchGetNextError = + | WorkspaceHistorySearchGetNextErrorInternal + + // WorkspaceHistorySetTimestampOfInterestError export interface WorkspaceHistorySetTimestampOfInterestErrorEntryNotFound { tag: "WorkspaceHistorySetTimestampOfInterestErrorEntryNotFound" @@ -4779,6 +4822,33 @@ export type WorkspaceRemoveEntryError = | WorkspaceRemoveEntryErrorStopped +// WorkspaceSearchCloseError +export interface WorkspaceSearchCloseErrorInternal { + tag: "WorkspaceSearchCloseErrorInternal" + error: string +} +export type WorkspaceSearchCloseError = + | WorkspaceSearchCloseErrorInternal + + +// WorkspaceSearchError +export interface WorkspaceSearchErrorInternal { + tag: "WorkspaceSearchErrorInternal" + error: string +} +export type WorkspaceSearchError = + | WorkspaceSearchErrorInternal + + +// WorkspaceSearchGetNextError +export interface WorkspaceSearchGetNextErrorInternal { + tag: "WorkspaceSearchGetNextErrorInternal" + error: string +} +export type WorkspaceSearchGetNextError = + | WorkspaceSearchGetNextErrorInternal + + // WorkspaceStatEntryError export interface WorkspaceStatEntryErrorEntryNotFound { tag: "WorkspaceStatEntryErrorEntryNotFound" @@ -5660,6 +5730,17 @@ export function workspaceHistoryOpenFileById( workspace_history: number, entry_id: string ): Promise> +export function workspaceHistorySearch( + workspace_history: number, + path: string, + query: string +): Promise> +export function workspaceHistorySearchClose( + search: number +): Promise> +export function workspaceHistorySearchGetNext( + search: number +): Promise> export function workspaceHistorySetTimestampOfInterest( workspace_history: number, toi: number @@ -5742,6 +5823,17 @@ export function workspaceRenameEntryById( dst_name: string, mode: MoveEntryMode ): Promise> +export function workspaceSearch( + workspace: number, + path: string, + query: string +): Promise> +export function workspaceSearchClose( + search: number +): Promise> +export function workspaceSearchGetNext( + search: number +): Promise> export function workspaceStatEntry( workspace: number, path: string diff --git a/bindings/electron/src/meths.rs b/bindings/electron/src/meths.rs index fe226bfa177..99026a1548d 100644 --- a/bindings/electron/src/meths.rs +++ b/bindings/electron/src/meths.rs @@ -4888,6 +4888,100 @@ fn struct_workspace_history_file_stat_rs_to_js<'a>( Ok(js_obj) } +// WorkspaceHistorySearchMatch + +#[allow(dead_code)] +fn struct_workspace_history_search_match_js_to_rs<'a>( + cx: &mut impl Context<'a>, + obj: Handle<'a, JsObject>, +) -> NeonResult { + let path = { + let js_val: Handle = obj.get(cx, "path")?; + { + let custom_from_rs_string = |s: String| -> Result<_, String> { + s.parse::().map_err(|e| e.to_string()) + }; + match custom_from_rs_string(js_val.value(cx)) { + Ok(val) => val, + Err(err) => return cx.throw_type_error(err), + } + } + }; + let stat = { + let js_val: Handle = obj.get(cx, "stat")?; + variant_workspace_history_entry_stat_js_to_rs(cx, js_val)? + }; + let score = { + let js_val: Handle = obj.get(cx, "score")?; + { + let v = js_val.value(cx); + if v < (u32::MIN as f64) || (u32::MAX as f64) < v { + cx.throw_type_error("Not an u32 number")? + } + let v = v as u32; + v + } + }; + let match_positions = { + let js_val: Handle = obj.get(cx, "matchPositions")?; + { + let size = js_val.len(cx); + let mut v = Vec::with_capacity(size as usize); + for i in 0..size { + let js_item: Handle = js_val.get(cx, i)?; + v.push({ + let v = js_item.value(cx); + if v < (u32::MIN as f64) || (u32::MAX as f64) < v { + cx.throw_type_error("Not an u32 number")? + } + let v = v as u32; + v + }); + } + v + } + }; + Ok(libparsec::WorkspaceHistorySearchMatch { + path, + stat, + score, + match_positions, + }) +} + +#[allow(dead_code)] +fn struct_workspace_history_search_match_rs_to_js<'a>( + cx: &mut impl Context<'a>, + rs_obj: libparsec::WorkspaceHistorySearchMatch, +) -> NeonResult> { + let js_obj = cx.empty_object(); + let js_path = JsString::try_new(cx, { + let custom_to_rs_string = + |v| -> Result<_, std::convert::Infallible> { Ok(std::string::ToString::to_string(&v)) }; + match custom_to_rs_string(rs_obj.path) { + Ok(ok) => ok, + Err(err) => return cx.throw_type_error(err.to_string()), + } + }) + .or_throw(cx)?; + js_obj.set(cx, "path", js_path)?; + let js_stat = variant_workspace_history_entry_stat_rs_to_js(cx, rs_obj.stat)?; + js_obj.set(cx, "stat", js_stat)?; + let js_score = JsNumber::new(cx, rs_obj.score as f64); + js_obj.set(cx, "score", js_score)?; + let js_match_positions = { + // JsArray::new allocates with `undefined` value, that's why we `set` value + let js_array = JsArray::new(cx, rs_obj.match_positions.len()); + for (i, elem) in rs_obj.match_positions.into_iter().enumerate() { + let js_elem = JsNumber::new(cx, elem as f64); + js_array.set(cx, i as u32, js_elem)?; + } + js_array + }; + js_obj.set(cx, "matchPositions", js_match_positions)?; + Ok(js_obj) +} + // WorkspaceInfo #[allow(dead_code)] @@ -4971,6 +5065,100 @@ fn struct_workspace_info_rs_to_js<'a>( Ok(js_obj) } +// WorkspaceSearchMatch + +#[allow(dead_code)] +fn struct_workspace_search_match_js_to_rs<'a>( + cx: &mut impl Context<'a>, + obj: Handle<'a, JsObject>, +) -> NeonResult { + let path = { + let js_val: Handle = obj.get(cx, "path")?; + { + let custom_from_rs_string = |s: String| -> Result<_, String> { + s.parse::().map_err(|e| e.to_string()) + }; + match custom_from_rs_string(js_val.value(cx)) { + Ok(val) => val, + Err(err) => return cx.throw_type_error(err), + } + } + }; + let stat = { + let js_val: Handle = obj.get(cx, "stat")?; + variant_entry_stat_js_to_rs(cx, js_val)? + }; + let score = { + let js_val: Handle = obj.get(cx, "score")?; + { + let v = js_val.value(cx); + if v < (u32::MIN as f64) || (u32::MAX as f64) < v { + cx.throw_type_error("Not an u32 number")? + } + let v = v as u32; + v + } + }; + let match_positions = { + let js_val: Handle = obj.get(cx, "matchPositions")?; + { + let size = js_val.len(cx); + let mut v = Vec::with_capacity(size as usize); + for i in 0..size { + let js_item: Handle = js_val.get(cx, i)?; + v.push({ + let v = js_item.value(cx); + if v < (u32::MIN as f64) || (u32::MAX as f64) < v { + cx.throw_type_error("Not an u32 number")? + } + let v = v as u32; + v + }); + } + v + } + }; + Ok(libparsec::WorkspaceSearchMatch { + path, + stat, + score, + match_positions, + }) +} + +#[allow(dead_code)] +fn struct_workspace_search_match_rs_to_js<'a>( + cx: &mut impl Context<'a>, + rs_obj: libparsec::WorkspaceSearchMatch, +) -> NeonResult> { + let js_obj = cx.empty_object(); + let js_path = JsString::try_new(cx, { + let custom_to_rs_string = + |v| -> Result<_, std::convert::Infallible> { Ok(std::string::ToString::to_string(&v)) }; + match custom_to_rs_string(rs_obj.path) { + Ok(ok) => ok, + Err(err) => return cx.throw_type_error(err.to_string()), + } + }) + .or_throw(cx)?; + js_obj.set(cx, "path", js_path)?; + let js_stat = variant_entry_stat_rs_to_js(cx, rs_obj.stat)?; + js_obj.set(cx, "stat", js_stat)?; + let js_score = JsNumber::new(cx, rs_obj.score as f64); + js_obj.set(cx, "score", js_score)?; + let js_match_positions = { + // JsArray::new allocates with `undefined` value, that's why we `set` value + let js_array = JsArray::new(cx, rs_obj.match_positions.len()); + for (i, elem) in rs_obj.match_positions.into_iter().enumerate() { + let js_elem = JsNumber::new(cx, elem as f64); + js_array.set(cx, i as u32, js_elem)?; + } + js_array + }; + js_obj.set(cx, "matchPositions", js_match_positions)?; + Ok(js_obj) +} + // WorkspaceUserAccessInfo #[allow(dead_code)] @@ -17057,6 +17245,66 @@ fn variant_workspace_history_realm_export_decryptor_rs_to_js<'a>( Ok(js_obj) } +// WorkspaceHistorySearchCloseError + +#[allow(dead_code)] +fn variant_workspace_history_search_close_error_rs_to_js<'a>( + cx: &mut impl Context<'a>, + rs_obj: libparsec::WorkspaceHistorySearchCloseError, +) -> NeonResult> { + let js_obj = cx.empty_object(); + let js_display = JsString::try_new(cx, &rs_obj.to_string()).or_throw(cx)?; + js_obj.set(cx, "error", js_display)?; + match rs_obj { + libparsec::WorkspaceHistorySearchCloseError::Internal { .. } => { + let js_tag = + JsString::try_new(cx, "WorkspaceHistorySearchCloseErrorInternal").or_throw(cx)?; + js_obj.set(cx, "tag", js_tag)?; + } + } + Ok(js_obj) +} + +// WorkspaceHistorySearchError + +#[allow(dead_code)] +fn variant_workspace_history_search_error_rs_to_js<'a>( + cx: &mut impl Context<'a>, + rs_obj: libparsec::WorkspaceHistorySearchError, +) -> NeonResult> { + let js_obj = cx.empty_object(); + let js_display = JsString::try_new(cx, &rs_obj.to_string()).or_throw(cx)?; + js_obj.set(cx, "error", js_display)?; + match rs_obj { + libparsec::WorkspaceHistorySearchError::Internal { .. } => { + let js_tag = + JsString::try_new(cx, "WorkspaceHistorySearchErrorInternal").or_throw(cx)?; + js_obj.set(cx, "tag", js_tag)?; + } + } + Ok(js_obj) +} + +// WorkspaceHistorySearchGetNextError + +#[allow(dead_code)] +fn variant_workspace_history_search_get_next_error_rs_to_js<'a>( + cx: &mut impl Context<'a>, + rs_obj: libparsec::WorkspaceHistorySearchGetNextError, +) -> NeonResult> { + let js_obj = cx.empty_object(); + let js_display = JsString::try_new(cx, &rs_obj.to_string()).or_throw(cx)?; + js_obj.set(cx, "error", js_display)?; + match rs_obj { + libparsec::WorkspaceHistorySearchGetNextError::Internal { .. } => { + let js_tag = + JsString::try_new(cx, "WorkspaceHistorySearchGetNextErrorInternal").or_throw(cx)?; + js_obj.set(cx, "tag", js_tag)?; + } + } + Ok(js_obj) +} + // WorkspaceHistorySetTimestampOfInterestError #[allow(dead_code)] @@ -17692,6 +17940,64 @@ fn variant_workspace_remove_entry_error_rs_to_js<'a>( Ok(js_obj) } +// WorkspaceSearchCloseError + +#[allow(dead_code)] +fn variant_workspace_search_close_error_rs_to_js<'a>( + cx: &mut impl Context<'a>, + rs_obj: libparsec::WorkspaceSearchCloseError, +) -> NeonResult> { + let js_obj = cx.empty_object(); + let js_display = JsString::try_new(cx, &rs_obj.to_string()).or_throw(cx)?; + js_obj.set(cx, "error", js_display)?; + match rs_obj { + libparsec::WorkspaceSearchCloseError::Internal { .. } => { + let js_tag = JsString::try_new(cx, "WorkspaceSearchCloseErrorInternal").or_throw(cx)?; + js_obj.set(cx, "tag", js_tag)?; + } + } + Ok(js_obj) +} + +// WorkspaceSearchError + +#[allow(dead_code)] +fn variant_workspace_search_error_rs_to_js<'a>( + cx: &mut impl Context<'a>, + rs_obj: libparsec::WorkspaceSearchError, +) -> NeonResult> { + let js_obj = cx.empty_object(); + let js_display = JsString::try_new(cx, &rs_obj.to_string()).or_throw(cx)?; + js_obj.set(cx, "error", js_display)?; + match rs_obj { + libparsec::WorkspaceSearchError::Internal { .. } => { + let js_tag = JsString::try_new(cx, "WorkspaceSearchErrorInternal").or_throw(cx)?; + js_obj.set(cx, "tag", js_tag)?; + } + } + Ok(js_obj) +} + +// WorkspaceSearchGetNextError + +#[allow(dead_code)] +fn variant_workspace_search_get_next_error_rs_to_js<'a>( + cx: &mut impl Context<'a>, + rs_obj: libparsec::WorkspaceSearchGetNextError, +) -> NeonResult> { + let js_obj = cx.empty_object(); + let js_display = JsString::try_new(cx, &rs_obj.to_string()).or_throw(cx)?; + js_obj.set(cx, "error", js_display)?; + match rs_obj { + libparsec::WorkspaceSearchGetNextError::Internal { .. } => { + let js_tag = + JsString::try_new(cx, "WorkspaceSearchGetNextErrorInternal").or_throw(cx)?; + js_obj.set(cx, "tag", js_tag)?; + } + } + Ok(js_obj) +} + // WorkspaceStatEntryError #[allow(dead_code)] @@ -28741,6 +29047,171 @@ fn workspace_history_open_file_by_id(mut cx: FunctionContext) -> JsResult JsResult { + crate::init_sentry(); + let workspace_history = { + let js_val = cx.argument::(0)?; + { + let v = js_val.value(&mut cx); + if v < (u32::MIN as f64) || (u32::MAX as f64) < v { + cx.throw_type_error("Not an u32 number")? + } + let v = v as u32; + v + } + }; + let path = { + let js_val = cx.argument::(1)?; + { + let custom_from_rs_string = |s: String| -> Result<_, String> { + s.parse::().map_err(|e| e.to_string()) + }; + match custom_from_rs_string(js_val.value(&mut cx)) { + Ok(val) => val, + Err(err) => return cx.throw_type_error(err), + } + } + }; + let query = { + let js_val = cx.argument::(2)?; + js_val.value(&mut cx) + }; + let channel = cx.channel(); + let (deferred, promise) = cx.promise(); + + // TODO: Promises are not cancellable in Javascript by default, should we add a custom cancel method ? + let _handle = crate::TOKIO_RUNTIME + .lock() + .expect("Mutex is poisoned") + .spawn(async move { + let ret = libparsec::workspace_history_search(workspace_history, path, &query).await; + + deferred.settle_with(&channel, move |mut cx| { + let js_ret = match ret { + Ok(ok) => { + let js_obj = JsObject::new(&mut cx); + let js_tag = JsBoolean::new(&mut cx, true); + js_obj.set(&mut cx, "ok", js_tag)?; + let js_value = JsNumber::new(&mut cx, ok as f64); + js_obj.set(&mut cx, "value", js_value)?; + js_obj + } + Err(err) => { + let js_obj = cx.empty_object(); + let js_tag = JsBoolean::new(&mut cx, false); + js_obj.set(&mut cx, "ok", js_tag)?; + let js_err = variant_workspace_history_search_error_rs_to_js(&mut cx, err)?; + js_obj.set(&mut cx, "error", js_err)?; + js_obj + } + }; + Ok(js_ret) + }); + }); + + Ok(promise) +} + +// workspace_history_search_close +fn workspace_history_search_close(mut cx: FunctionContext) -> JsResult { + crate::init_sentry(); + let search = { + let js_val = cx.argument::(0)?; + { + let v = js_val.value(&mut cx); + if v < (u32::MIN as f64) || (u32::MAX as f64) < v { + cx.throw_type_error("Not an u32 number")? + } + let v = v as u32; + v + } + }; + let ret = libparsec::workspace_history_search_close(search); + let js_ret = match ret { + Ok(ok) => { + let js_obj = JsObject::new(&mut cx); + let js_tag = JsBoolean::new(&mut cx, true); + js_obj.set(&mut cx, "ok", js_tag)?; + let js_value = { + #[allow(clippy::let_unit_value)] + let _ = ok; + JsNull::new(&mut cx) + }; + js_obj.set(&mut cx, "value", js_value)?; + js_obj + } + Err(err) => { + let js_obj = cx.empty_object(); + let js_tag = JsBoolean::new(&mut cx, false); + js_obj.set(&mut cx, "ok", js_tag)?; + let js_err = variant_workspace_history_search_close_error_rs_to_js(&mut cx, err)?; + js_obj.set(&mut cx, "error", js_err)?; + js_obj + } + }; + let (deferred, promise) = cx.promise(); + deferred.resolve(&mut cx, js_ret); + Ok(promise) +} + +// workspace_history_search_get_next +fn workspace_history_search_get_next(mut cx: FunctionContext) -> JsResult { + crate::init_sentry(); + let search = { + let js_val = cx.argument::(0)?; + { + let v = js_val.value(&mut cx); + if v < (u32::MIN as f64) || (u32::MAX as f64) < v { + cx.throw_type_error("Not an u32 number")? + } + let v = v as u32; + v + } + }; + let channel = cx.channel(); + let (deferred, promise) = cx.promise(); + + // TODO: Promises are not cancellable in Javascript by default, should we add a custom cancel method ? + let _handle = crate::TOKIO_RUNTIME + .lock() + .expect("Mutex is poisoned") + .spawn(async move { + let ret = libparsec::workspace_history_search_get_next(search).await; + + deferred.settle_with(&channel, move |mut cx| { + let js_ret = match ret { + Ok(ok) => { + let js_obj = JsObject::new(&mut cx); + let js_tag = JsBoolean::new(&mut cx, true); + js_obj.set(&mut cx, "ok", js_tag)?; + let js_value = match ok { + Some(elem) => { + struct_workspace_history_search_match_rs_to_js(&mut cx, elem)? + .as_value(&mut cx) + } + None => JsNull::new(&mut cx).as_value(&mut cx), + }; + js_obj.set(&mut cx, "value", js_value)?; + js_obj + } + Err(err) => { + let js_obj = cx.empty_object(); + let js_tag = JsBoolean::new(&mut cx, false); + js_obj.set(&mut cx, "ok", js_tag)?; + let js_err = + variant_workspace_history_search_get_next_error_rs_to_js(&mut cx, err)?; + js_obj.set(&mut cx, "error", js_err)?; + js_obj + } + }; + Ok(js_ret) + }); + }); + + Ok(promise) +} + // workspace_history_set_timestamp_of_interest fn workspace_history_set_timestamp_of_interest(mut cx: FunctionContext) -> JsResult { crate::init_sentry(); @@ -30099,6 +30570,169 @@ fn workspace_rename_entry_by_id(mut cx: FunctionContext) -> JsResult Ok(promise) } +// workspace_search +fn workspace_search(mut cx: FunctionContext) -> JsResult { + crate::init_sentry(); + let workspace = { + let js_val = cx.argument::(0)?; + { + let v = js_val.value(&mut cx); + if v < (u32::MIN as f64) || (u32::MAX as f64) < v { + cx.throw_type_error("Not an u32 number")? + } + let v = v as u32; + v + } + }; + let path = { + let js_val = cx.argument::(1)?; + { + let custom_from_rs_string = |s: String| -> Result<_, String> { + s.parse::().map_err(|e| e.to_string()) + }; + match custom_from_rs_string(js_val.value(&mut cx)) { + Ok(val) => val, + Err(err) => return cx.throw_type_error(err), + } + } + }; + let query = { + let js_val = cx.argument::(2)?; + js_val.value(&mut cx) + }; + let channel = cx.channel(); + let (deferred, promise) = cx.promise(); + + // TODO: Promises are not cancellable in Javascript by default, should we add a custom cancel method ? + let _handle = crate::TOKIO_RUNTIME + .lock() + .expect("Mutex is poisoned") + .spawn(async move { + let ret = libparsec::workspace_search(workspace, path, &query).await; + + deferred.settle_with(&channel, move |mut cx| { + let js_ret = match ret { + Ok(ok) => { + let js_obj = JsObject::new(&mut cx); + let js_tag = JsBoolean::new(&mut cx, true); + js_obj.set(&mut cx, "ok", js_tag)?; + let js_value = JsNumber::new(&mut cx, ok as f64); + js_obj.set(&mut cx, "value", js_value)?; + js_obj + } + Err(err) => { + let js_obj = cx.empty_object(); + let js_tag = JsBoolean::new(&mut cx, false); + js_obj.set(&mut cx, "ok", js_tag)?; + let js_err = variant_workspace_search_error_rs_to_js(&mut cx, err)?; + js_obj.set(&mut cx, "error", js_err)?; + js_obj + } + }; + Ok(js_ret) + }); + }); + + Ok(promise) +} + +// workspace_search_close +fn workspace_search_close(mut cx: FunctionContext) -> JsResult { + crate::init_sentry(); + let search = { + let js_val = cx.argument::(0)?; + { + let v = js_val.value(&mut cx); + if v < (u32::MIN as f64) || (u32::MAX as f64) < v { + cx.throw_type_error("Not an u32 number")? + } + let v = v as u32; + v + } + }; + let ret = libparsec::workspace_search_close(search); + let js_ret = match ret { + Ok(ok) => { + let js_obj = JsObject::new(&mut cx); + let js_tag = JsBoolean::new(&mut cx, true); + js_obj.set(&mut cx, "ok", js_tag)?; + let js_value = { + #[allow(clippy::let_unit_value)] + let _ = ok; + JsNull::new(&mut cx) + }; + js_obj.set(&mut cx, "value", js_value)?; + js_obj + } + Err(err) => { + let js_obj = cx.empty_object(); + let js_tag = JsBoolean::new(&mut cx, false); + js_obj.set(&mut cx, "ok", js_tag)?; + let js_err = variant_workspace_search_close_error_rs_to_js(&mut cx, err)?; + js_obj.set(&mut cx, "error", js_err)?; + js_obj + } + }; + let (deferred, promise) = cx.promise(); + deferred.resolve(&mut cx, js_ret); + Ok(promise) +} + +// workspace_search_get_next +fn workspace_search_get_next(mut cx: FunctionContext) -> JsResult { + crate::init_sentry(); + let search = { + let js_val = cx.argument::(0)?; + { + let v = js_val.value(&mut cx); + if v < (u32::MIN as f64) || (u32::MAX as f64) < v { + cx.throw_type_error("Not an u32 number")? + } + let v = v as u32; + v + } + }; + let channel = cx.channel(); + let (deferred, promise) = cx.promise(); + + // TODO: Promises are not cancellable in Javascript by default, should we add a custom cancel method ? + let _handle = crate::TOKIO_RUNTIME + .lock() + .expect("Mutex is poisoned") + .spawn(async move { + let ret = libparsec::workspace_search_get_next(search).await; + + deferred.settle_with(&channel, move |mut cx| { + let js_ret = match ret { + Ok(ok) => { + let js_obj = JsObject::new(&mut cx); + let js_tag = JsBoolean::new(&mut cx, true); + js_obj.set(&mut cx, "ok", js_tag)?; + let js_value = match ok { + Some(elem) => struct_workspace_search_match_rs_to_js(&mut cx, elem)? + .as_value(&mut cx), + None => JsNull::new(&mut cx).as_value(&mut cx), + }; + js_obj.set(&mut cx, "value", js_value)?; + js_obj + } + Err(err) => { + let js_obj = cx.empty_object(); + let js_tag = JsBoolean::new(&mut cx, false); + js_obj.set(&mut cx, "ok", js_tag)?; + let js_err = + variant_workspace_search_get_next_error_rs_to_js(&mut cx, err)?; + js_obj.set(&mut cx, "error", js_err)?; + js_obj + } + }; + Ok(js_ret) + }); + }); + + Ok(promise) +} + // workspace_stat_entry fn workspace_stat_entry(mut cx: FunctionContext) -> JsResult { crate::init_sentry(); @@ -30983,6 +31617,15 @@ pub fn register_meths(cx: &mut ModuleContext) -> NeonResult<()> { "workspaceHistoryOpenFileById", workspace_history_open_file_by_id, )?; + cx.export_function("workspaceHistorySearch", workspace_history_search)?; + cx.export_function( + "workspaceHistorySearchClose", + workspace_history_search_close, + )?; + cx.export_function( + "workspaceHistorySearchGetNext", + workspace_history_search_get_next, + )?; cx.export_function( "workspaceHistorySetTimestampOfInterest", workspace_history_set_timestamp_of_interest, @@ -31020,6 +31663,9 @@ pub fn register_meths(cx: &mut ModuleContext) -> NeonResult<()> { cx.export_function("workspaceRemoveFolder", workspace_remove_folder)?; cx.export_function("workspaceRemoveFolderAll", workspace_remove_folder_all)?; cx.export_function("workspaceRenameEntryById", workspace_rename_entry_by_id)?; + cx.export_function("workspaceSearch", workspace_search)?; + cx.export_function("workspaceSearchClose", workspace_search_close)?; + cx.export_function("workspaceSearchGetNext", workspace_search_get_next)?; cx.export_function("workspaceStatEntry", workspace_stat_entry)?; cx.export_function("workspaceStatEntryById", workspace_stat_entry_by_id)?; cx.export_function( diff --git a/bindings/web/src/meths.rs b/bindings/web/src/meths.rs index c4d116e9b63..47558eb6d38 100644 --- a/bindings/web/src/meths.rs +++ b/bindings/web/src/meths.rs @@ -5114,6 +5114,108 @@ fn struct_workspace_history_file_stat_rs_to_js( Ok(js_obj) } +// WorkspaceHistorySearchMatch + +#[allow(dead_code)] +fn struct_workspace_history_search_match_js_to_rs( + obj: JsValue, +) -> Result { + let path = { + let js_val = Reflect::get(&obj, &"path".into())?; + js_val + .dyn_into::() + .ok() + .and_then(|s| s.as_string()) + .ok_or_else(|| TypeError::new("Not a string")) + .and_then(|x| { + let custom_from_rs_string = |s: String| -> Result<_, String> { + s.parse::().map_err(|e| e.to_string()) + }; + custom_from_rs_string(x).map_err(|e| TypeError::new(e.as_ref())) + })? + }; + let stat = { + let js_val = Reflect::get(&obj, &"stat".into())?; + variant_workspace_history_entry_stat_js_to_rs(js_val)? + }; + let score = { + let js_val = Reflect::get(&obj, &"score".into())?; + { + let v = js_val + .dyn_into::() + .map_err(|_| TypeError::new("Not a number"))? + .value_of(); + if v < (u32::MIN as f64) || (u32::MAX as f64) < v { + return Err(JsValue::from(TypeError::new("Not an u32 number"))); + } + let v = v as u32; + v + } + }; + let match_positions = { + let js_val = Reflect::get(&obj, &"matchPositions".into())?; + { + let js_val = js_val + .dyn_into::() + .map_err(|_| TypeError::new("Not an array"))?; + let mut converted = Vec::with_capacity(js_val.length() as usize); + for x in js_val.iter() { + let x_converted = { + let v = x + .dyn_into::() + .map_err(|_| TypeError::new("Not a number"))? + .value_of(); + if v < (u32::MIN as f64) || (u32::MAX as f64) < v { + return Err(JsValue::from(TypeError::new("Not an u32 number"))); + } + let v = v as u32; + v + }; + converted.push(x_converted); + } + converted + } + }; + Ok(libparsec::WorkspaceHistorySearchMatch { + path, + stat, + score, + match_positions, + }) +} + +#[allow(dead_code)] +fn struct_workspace_history_search_match_rs_to_js( + rs_obj: libparsec::WorkspaceHistorySearchMatch, +) -> Result { + let js_obj = Object::new().into(); + let js_path = JsValue::from_str({ + let custom_to_rs_string = + |v| -> Result<_, std::convert::Infallible> { Ok(std::string::ToString::to_string(&v)) }; + match custom_to_rs_string(rs_obj.path) { + Ok(ok) => ok, + Err(err) => return Err(JsValue::from(TypeError::new(&err.to_string()))), + } + .as_ref() + }); + Reflect::set(&js_obj, &"path".into(), &js_path)?; + let js_stat = variant_workspace_history_entry_stat_rs_to_js(rs_obj.stat)?; + Reflect::set(&js_obj, &"stat".into(), &js_stat)?; + let js_score = JsValue::from(rs_obj.score); + Reflect::set(&js_obj, &"score".into(), &js_score)?; + let js_match_positions = { + // Array::new_with_length allocates with `undefined` value, that's why we `set` value + let js_array = Array::new_with_length(rs_obj.match_positions.len() as u32); + for (i, elem) in rs_obj.match_positions.into_iter().enumerate() { + let js_elem = JsValue::from(elem); + js_array.set(i as u32, js_elem); + } + js_array.into() + }; + Reflect::set(&js_obj, &"matchPositions".into(), &js_match_positions)?; + Ok(js_obj) +} + // WorkspaceInfo #[allow(dead_code)] @@ -5205,6 +5307,108 @@ fn struct_workspace_info_rs_to_js(rs_obj: libparsec::WorkspaceInfo) -> Result Result { + let path = { + let js_val = Reflect::get(&obj, &"path".into())?; + js_val + .dyn_into::() + .ok() + .and_then(|s| s.as_string()) + .ok_or_else(|| TypeError::new("Not a string")) + .and_then(|x| { + let custom_from_rs_string = |s: String| -> Result<_, String> { + s.parse::().map_err(|e| e.to_string()) + }; + custom_from_rs_string(x).map_err(|e| TypeError::new(e.as_ref())) + })? + }; + let stat = { + let js_val = Reflect::get(&obj, &"stat".into())?; + variant_entry_stat_js_to_rs(js_val)? + }; + let score = { + let js_val = Reflect::get(&obj, &"score".into())?; + { + let v = js_val + .dyn_into::() + .map_err(|_| TypeError::new("Not a number"))? + .value_of(); + if v < (u32::MIN as f64) || (u32::MAX as f64) < v { + return Err(JsValue::from(TypeError::new("Not an u32 number"))); + } + let v = v as u32; + v + } + }; + let match_positions = { + let js_val = Reflect::get(&obj, &"matchPositions".into())?; + { + let js_val = js_val + .dyn_into::() + .map_err(|_| TypeError::new("Not an array"))?; + let mut converted = Vec::with_capacity(js_val.length() as usize); + for x in js_val.iter() { + let x_converted = { + let v = x + .dyn_into::() + .map_err(|_| TypeError::new("Not a number"))? + .value_of(); + if v < (u32::MIN as f64) || (u32::MAX as f64) < v { + return Err(JsValue::from(TypeError::new("Not an u32 number"))); + } + let v = v as u32; + v + }; + converted.push(x_converted); + } + converted + } + }; + Ok(libparsec::WorkspaceSearchMatch { + path, + stat, + score, + match_positions, + }) +} + +#[allow(dead_code)] +fn struct_workspace_search_match_rs_to_js( + rs_obj: libparsec::WorkspaceSearchMatch, +) -> Result { + let js_obj = Object::new().into(); + let js_path = JsValue::from_str({ + let custom_to_rs_string = + |v| -> Result<_, std::convert::Infallible> { Ok(std::string::ToString::to_string(&v)) }; + match custom_to_rs_string(rs_obj.path) { + Ok(ok) => ok, + Err(err) => return Err(JsValue::from(TypeError::new(&err.to_string()))), + } + .as_ref() + }); + Reflect::set(&js_obj, &"path".into(), &js_path)?; + let js_stat = variant_entry_stat_rs_to_js(rs_obj.stat)?; + Reflect::set(&js_obj, &"stat".into(), &js_stat)?; + let js_score = JsValue::from(rs_obj.score); + Reflect::set(&js_obj, &"score".into(), &js_score)?; + let js_match_positions = { + // Array::new_with_length allocates with `undefined` value, that's why we `set` value + let js_array = Array::new_with_length(rs_obj.match_positions.len() as u32); + for (i, elem) in rs_obj.match_positions.into_iter().enumerate() { + let js_elem = JsValue::from(elem); + js_array.set(i as u32, js_elem); + } + js_array.into() + }; + Reflect::set(&js_obj, &"matchPositions".into(), &js_match_positions)?; + Ok(js_obj) +} + // WorkspaceUserAccessInfo #[allow(dead_code)] @@ -18831,6 +19035,69 @@ fn variant_workspace_history_realm_export_decryptor_rs_to_js( Ok(js_obj) } +// WorkspaceHistorySearchCloseError + +#[allow(dead_code)] +fn variant_workspace_history_search_close_error_rs_to_js( + rs_obj: libparsec::WorkspaceHistorySearchCloseError, +) -> Result { + let js_obj = Object::new().into(); + let js_display = &rs_obj.to_string(); + Reflect::set(&js_obj, &"error".into(), &js_display.into())?; + match rs_obj { + libparsec::WorkspaceHistorySearchCloseError::Internal { .. } => { + Reflect::set( + &js_obj, + &"tag".into(), + &"WorkspaceHistorySearchCloseErrorInternal".into(), + )?; + } + } + Ok(js_obj) +} + +// WorkspaceHistorySearchError + +#[allow(dead_code)] +fn variant_workspace_history_search_error_rs_to_js( + rs_obj: libparsec::WorkspaceHistorySearchError, +) -> Result { + let js_obj = Object::new().into(); + let js_display = &rs_obj.to_string(); + Reflect::set(&js_obj, &"error".into(), &js_display.into())?; + match rs_obj { + libparsec::WorkspaceHistorySearchError::Internal { .. } => { + Reflect::set( + &js_obj, + &"tag".into(), + &"WorkspaceHistorySearchErrorInternal".into(), + )?; + } + } + Ok(js_obj) +} + +// WorkspaceHistorySearchGetNextError + +#[allow(dead_code)] +fn variant_workspace_history_search_get_next_error_rs_to_js( + rs_obj: libparsec::WorkspaceHistorySearchGetNextError, +) -> Result { + let js_obj = Object::new().into(); + let js_display = &rs_obj.to_string(); + Reflect::set(&js_obj, &"error".into(), &js_display.into())?; + match rs_obj { + libparsec::WorkspaceHistorySearchGetNextError::Internal { .. } => { + Reflect::set( + &js_obj, + &"tag".into(), + &"WorkspaceHistorySearchGetNextErrorInternal".into(), + )?; + } + } + Ok(js_obj) +} + // WorkspaceHistorySetTimestampOfInterestError #[allow(dead_code)] @@ -19597,6 +19864,69 @@ fn variant_workspace_remove_entry_error_rs_to_js( Ok(js_obj) } +// WorkspaceSearchCloseError + +#[allow(dead_code)] +fn variant_workspace_search_close_error_rs_to_js( + rs_obj: libparsec::WorkspaceSearchCloseError, +) -> Result { + let js_obj = Object::new().into(); + let js_display = &rs_obj.to_string(); + Reflect::set(&js_obj, &"error".into(), &js_display.into())?; + match rs_obj { + libparsec::WorkspaceSearchCloseError::Internal { .. } => { + Reflect::set( + &js_obj, + &"tag".into(), + &"WorkspaceSearchCloseErrorInternal".into(), + )?; + } + } + Ok(js_obj) +} + +// WorkspaceSearchError + +#[allow(dead_code)] +fn variant_workspace_search_error_rs_to_js( + rs_obj: libparsec::WorkspaceSearchError, +) -> Result { + let js_obj = Object::new().into(); + let js_display = &rs_obj.to_string(); + Reflect::set(&js_obj, &"error".into(), &js_display.into())?; + match rs_obj { + libparsec::WorkspaceSearchError::Internal { .. } => { + Reflect::set( + &js_obj, + &"tag".into(), + &"WorkspaceSearchErrorInternal".into(), + )?; + } + } + Ok(js_obj) +} + +// WorkspaceSearchGetNextError + +#[allow(dead_code)] +fn variant_workspace_search_get_next_error_rs_to_js( + rs_obj: libparsec::WorkspaceSearchGetNextError, +) -> Result { + let js_obj = Object::new().into(); + let js_display = &rs_obj.to_string(); + Reflect::set(&js_obj, &"error".into(), &js_display.into())?; + match rs_obj { + libparsec::WorkspaceSearchGetNextError::Internal { .. } => { + Reflect::set( + &js_obj, + &"tag".into(), + &"WorkspaceSearchGetNextErrorInternal".into(), + )?; + } + } + Ok(js_obj) +} + // WorkspaceStatEntryError #[allow(dead_code)] @@ -26034,6 +26364,94 @@ pub fn workspaceHistoryOpenFileById(workspace_history: u32, entry_id: String) -> })) } +// workspace_history_search +#[allow(non_snake_case)] +#[wasm_bindgen] +pub fn workspaceHistorySearch(workspace_history: u32, path: String, query: String) -> Promise { + future_to_promise(libparsec::WithTaskIDFuture::from(async move { + let path = { + let custom_from_rs_string = |s: String| -> Result<_, String> { + s.parse::().map_err(|e| e.to_string()) + }; + custom_from_rs_string(path).map_err(|e| TypeError::new(e.as_ref())) + }?; + + let ret = libparsec::workspace_history_search(workspace_history, path, &query).await; + Ok(match ret { + Ok(value) => { + let js_obj = Object::new().into(); + Reflect::set(&js_obj, &"ok".into(), &true.into())?; + let js_value = JsValue::from(value); + Reflect::set(&js_obj, &"value".into(), &js_value)?; + js_obj + } + Err(err) => { + let js_obj = Object::new().into(); + Reflect::set(&js_obj, &"ok".into(), &false.into())?; + let js_err = variant_workspace_history_search_error_rs_to_js(err)?; + Reflect::set(&js_obj, &"error".into(), &js_err)?; + js_obj + } + }) + })) +} + +// workspace_history_search_close +#[allow(non_snake_case)] +#[wasm_bindgen] +pub fn workspaceHistorySearchClose(search: u32) -> Promise { + future_to_promise(libparsec::WithTaskIDFuture::from(async move { + let ret = libparsec::workspace_history_search_close(search); + Ok(match ret { + Ok(value) => { + let js_obj = Object::new().into(); + Reflect::set(&js_obj, &"ok".into(), &true.into())?; + let js_value = { + let _ = value; + JsValue::null() + }; + Reflect::set(&js_obj, &"value".into(), &js_value)?; + js_obj + } + Err(err) => { + let js_obj = Object::new().into(); + Reflect::set(&js_obj, &"ok".into(), &false.into())?; + let js_err = variant_workspace_history_search_close_error_rs_to_js(err)?; + Reflect::set(&js_obj, &"error".into(), &js_err)?; + js_obj + } + }) + })) +} + +// workspace_history_search_get_next +#[allow(non_snake_case)] +#[wasm_bindgen] +pub fn workspaceHistorySearchGetNext(search: u32) -> Promise { + future_to_promise(libparsec::WithTaskIDFuture::from(async move { + let ret = libparsec::workspace_history_search_get_next(search).await; + Ok(match ret { + Ok(value) => { + let js_obj = Object::new().into(); + Reflect::set(&js_obj, &"ok".into(), &true.into())?; + let js_value = match value { + Some(val) => struct_workspace_history_search_match_rs_to_js(val)?, + None => JsValue::NULL, + }; + Reflect::set(&js_obj, &"value".into(), &js_value)?; + js_obj + } + Err(err) => { + let js_obj = Object::new().into(); + Reflect::set(&js_obj, &"ok".into(), &false.into())?; + let js_err = variant_workspace_history_search_get_next_error_rs_to_js(err)?; + Reflect::set(&js_obj, &"error".into(), &js_err)?; + js_obj + } + }) + })) +} + // workspace_history_set_timestamp_of_interest #[allow(non_snake_case)] #[wasm_bindgen] @@ -26801,6 +27219,94 @@ pub fn workspaceRenameEntryById( })) } +// workspace_search +#[allow(non_snake_case)] +#[wasm_bindgen] +pub fn workspaceSearch(workspace: u32, path: String, query: String) -> Promise { + future_to_promise(libparsec::WithTaskIDFuture::from(async move { + let path = { + let custom_from_rs_string = |s: String| -> Result<_, String> { + s.parse::().map_err(|e| e.to_string()) + }; + custom_from_rs_string(path).map_err(|e| TypeError::new(e.as_ref())) + }?; + + let ret = libparsec::workspace_search(workspace, path, &query).await; + Ok(match ret { + Ok(value) => { + let js_obj = Object::new().into(); + Reflect::set(&js_obj, &"ok".into(), &true.into())?; + let js_value = JsValue::from(value); + Reflect::set(&js_obj, &"value".into(), &js_value)?; + js_obj + } + Err(err) => { + let js_obj = Object::new().into(); + Reflect::set(&js_obj, &"ok".into(), &false.into())?; + let js_err = variant_workspace_search_error_rs_to_js(err)?; + Reflect::set(&js_obj, &"error".into(), &js_err)?; + js_obj + } + }) + })) +} + +// workspace_search_close +#[allow(non_snake_case)] +#[wasm_bindgen] +pub fn workspaceSearchClose(search: u32) -> Promise { + future_to_promise(libparsec::WithTaskIDFuture::from(async move { + let ret = libparsec::workspace_search_close(search); + Ok(match ret { + Ok(value) => { + let js_obj = Object::new().into(); + Reflect::set(&js_obj, &"ok".into(), &true.into())?; + let js_value = { + let _ = value; + JsValue::null() + }; + Reflect::set(&js_obj, &"value".into(), &js_value)?; + js_obj + } + Err(err) => { + let js_obj = Object::new().into(); + Reflect::set(&js_obj, &"ok".into(), &false.into())?; + let js_err = variant_workspace_search_close_error_rs_to_js(err)?; + Reflect::set(&js_obj, &"error".into(), &js_err)?; + js_obj + } + }) + })) +} + +// workspace_search_get_next +#[allow(non_snake_case)] +#[wasm_bindgen] +pub fn workspaceSearchGetNext(search: u32) -> Promise { + future_to_promise(libparsec::WithTaskIDFuture::from(async move { + let ret = libparsec::workspace_search_get_next(search).await; + Ok(match ret { + Ok(value) => { + let js_obj = Object::new().into(); + Reflect::set(&js_obj, &"ok".into(), &true.into())?; + let js_value = match value { + Some(val) => struct_workspace_search_match_rs_to_js(val)?, + None => JsValue::NULL, + }; + Reflect::set(&js_obj, &"value".into(), &js_value)?; + js_obj + } + Err(err) => { + let js_obj = Object::new().into(); + Reflect::set(&js_obj, &"ok".into(), &false.into())?; + let js_err = variant_workspace_search_get_next_error_rs_to_js(err)?; + Reflect::set(&js_obj, &"error".into(), &js_err)?; + js_obj + } + }) + })) +} + // workspace_stat_entry #[allow(non_snake_case)] #[wasm_bindgen] diff --git a/client/src/plugins/libparsec/definitions.ts b/client/src/plugins/libparsec/definitions.ts index 07bf60e2498..3a03632257f 100644 --- a/client/src/plugins/libparsec/definitions.ts +++ b/client/src/plugins/libparsec/definitions.ts @@ -530,6 +530,13 @@ export interface WorkspaceHistoryFileStat { size: SizeInt } +export interface WorkspaceHistorySearchMatch { + path: FsPath + stat: WorkspaceHistoryEntryStat + score: U32 + matchPositions: Array +} + export interface WorkspaceInfo { id: VlobID currentName: EntryName @@ -538,6 +545,13 @@ export interface WorkspaceInfo { isBootstrapped: boolean } +export interface WorkspaceSearchMatch { + path: FsPath + stat: EntryStat + score: U32 + matchPositions: Array +} + export interface WorkspaceUserAccessInfo { userId: UserID humanHandle: HumanHandle @@ -5185,6 +5199,42 @@ export type WorkspaceHistoryRealmExportDecryptor = | WorkspaceHistoryRealmExportDecryptorSequesterService | WorkspaceHistoryRealmExportDecryptorUser +// WorkspaceHistorySearchCloseError +export enum WorkspaceHistorySearchCloseErrorTag { + Internal = 'WorkspaceHistorySearchCloseErrorInternal', +} + +export interface WorkspaceHistorySearchCloseErrorInternal { + tag: WorkspaceHistorySearchCloseErrorTag.Internal + error: string +} +export type WorkspaceHistorySearchCloseError = + | WorkspaceHistorySearchCloseErrorInternal + +// WorkspaceHistorySearchError +export enum WorkspaceHistorySearchErrorTag { + Internal = 'WorkspaceHistorySearchErrorInternal', +} + +export interface WorkspaceHistorySearchErrorInternal { + tag: WorkspaceHistorySearchErrorTag.Internal + error: string +} +export type WorkspaceHistorySearchError = + | WorkspaceHistorySearchErrorInternal + +// WorkspaceHistorySearchGetNextError +export enum WorkspaceHistorySearchGetNextErrorTag { + Internal = 'WorkspaceHistorySearchGetNextErrorInternal', +} + +export interface WorkspaceHistorySearchGetNextErrorInternal { + tag: WorkspaceHistorySearchGetNextErrorTag.Internal + error: string +} +export type WorkspaceHistorySearchGetNextError = + | WorkspaceHistorySearchGetNextErrorInternal + // WorkspaceHistorySetTimestampOfInterestError export enum WorkspaceHistorySetTimestampOfInterestErrorTag { EntryNotFound = 'WorkspaceHistorySetTimestampOfInterestErrorEntryNotFound', @@ -5785,6 +5835,42 @@ export type WorkspaceRemoveEntryError = | WorkspaceRemoveEntryErrorReadOnlyRealm | WorkspaceRemoveEntryErrorStopped +// WorkspaceSearchCloseError +export enum WorkspaceSearchCloseErrorTag { + Internal = 'WorkspaceSearchCloseErrorInternal', +} + +export interface WorkspaceSearchCloseErrorInternal { + tag: WorkspaceSearchCloseErrorTag.Internal + error: string +} +export type WorkspaceSearchCloseError = + | WorkspaceSearchCloseErrorInternal + +// WorkspaceSearchError +export enum WorkspaceSearchErrorTag { + Internal = 'WorkspaceSearchErrorInternal', +} + +export interface WorkspaceSearchErrorInternal { + tag: WorkspaceSearchErrorTag.Internal + error: string +} +export type WorkspaceSearchError = + | WorkspaceSearchErrorInternal + +// WorkspaceSearchGetNextError +export enum WorkspaceSearchGetNextErrorTag { + Internal = 'WorkspaceSearchGetNextErrorInternal', +} + +export interface WorkspaceSearchGetNextErrorInternal { + tag: WorkspaceSearchGetNextErrorTag.Internal + error: string +} +export type WorkspaceSearchGetNextError = + | WorkspaceSearchGetNextErrorInternal + // WorkspaceStatEntryError export enum WorkspaceStatEntryErrorTag { EntryNotFound = 'WorkspaceStatEntryErrorEntryNotFound', @@ -6709,6 +6795,17 @@ export interface LibParsecPlugin { workspace_history: Handle, entry_id: VlobID ): Promise> + workspaceHistorySearch( + workspace_history: Handle, + path: FsPath, + query: string + ): Promise> + workspaceHistorySearchClose( + search: Handle + ): Promise> + workspaceHistorySearchGetNext( + search: Handle + ): Promise> workspaceHistorySetTimestampOfInterest( workspace_history: Handle, toi: DateTime @@ -6791,6 +6888,17 @@ export interface LibParsecPlugin { dst_name: EntryName, mode: MoveEntryMode ): Promise> + workspaceSearch( + workspace: Handle, + path: FsPath, + query: string + ): Promise> + workspaceSearchClose( + search: Handle + ): Promise> + workspaceSearchGetNext( + search: Handle + ): Promise> workspaceStatEntry( workspace: Handle, path: FsPath From 475be9469da29ee0e2e4bec5b02353002be25a8e Mon Sep 17 00:00:00 2001 From: Emmanuel Leblond Date: Wed, 4 Mar 2026 00:35:37 +0100 Subject: [PATCH 4/4] Implement file search in GUI --- .../WorkspaceHistorySearchResultList.vue | 179 +++++++++++++++++ .../explorer/WorkspaceSearchResultList.vue | 187 ++++++++++++++++++ client/src/components/files/explorer/index.ts | 4 + client/src/locales/en-US.json | 15 +- client/src/locales/fr-FR.json | 15 +- client/src/parsec/file.ts | 23 +++ client/src/parsec/history.ts | 21 ++ client/src/parsec/types.ts | 16 ++ client/src/views/files/FoldersPage.vue | 128 +++++++++++- .../views/workspaces/WorkspaceHistoryPage.vue | 164 +++++++++++---- 10 files changed, 714 insertions(+), 38 deletions(-) create mode 100644 client/src/components/files/explorer/WorkspaceHistorySearchResultList.vue create mode 100644 client/src/components/files/explorer/WorkspaceSearchResultList.vue diff --git a/client/src/components/files/explorer/WorkspaceHistorySearchResultList.vue b/client/src/components/files/explorer/WorkspaceHistorySearchResultList.vue new file mode 100644 index 00000000000..4faa2513f60 --- /dev/null +++ b/client/src/components/files/explorer/WorkspaceHistorySearchResultList.vue @@ -0,0 +1,179 @@ + + + + + + + diff --git a/client/src/components/files/explorer/WorkspaceSearchResultList.vue b/client/src/components/files/explorer/WorkspaceSearchResultList.vue new file mode 100644 index 00000000000..ec70314ce5d --- /dev/null +++ b/client/src/components/files/explorer/WorkspaceSearchResultList.vue @@ -0,0 +1,187 @@ + + + + + + + diff --git a/client/src/components/files/explorer/index.ts b/client/src/components/files/explorer/index.ts index e8e1dcfce32..55e0a7a2cad 100644 --- a/client/src/components/files/explorer/index.ts +++ b/client/src/components/files/explorer/index.ts @@ -12,6 +12,8 @@ import FileListItem from '@/components/files/explorer/FileListItem.vue'; import FileListItemProcessing from '@/components/files/explorer/FileListItemProcessing.vue'; import FolderSelectionModal from '@/components/files/explorer/FolderSelectionModal.vue'; import HistoryFileListItem from '@/components/files/explorer/HistoryFileListItem.vue'; +import WorkspaceHistorySearchResultList from '@/components/files/explorer/WorkspaceHistorySearchResultList.vue'; +import WorkspaceSearchResultList from '@/components/files/explorer/WorkspaceSearchResultList.vue'; export { FileCard, @@ -26,4 +28,6 @@ export { FileListItemProcessing, FolderSelectionModal, HistoryFileListItem, + WorkspaceHistorySearchResultList, + WorkspaceSearchResultList, }; diff --git a/client/src/locales/en-US.json b/client/src/locales/en-US.json index 374a3bef0ae..1d91e7ef5ff 100644 --- a/client/src/locales/en-US.json +++ b/client/src/locales/en-US.json @@ -853,6 +853,13 @@ "actionUnselectAll": "Unselect all", "actionShare": "Share" }, + "search": { + "placeholder": "Search files...", + "searching": "Searching...", + "searchingIn": "Searching in {folder}", + "noResults": "No results found.", + "minChars": "Please enter at least 3 characters to search." + }, "importModal": { "title": "Upload your files", "dragAndDrop": "Drag and drop your files here", @@ -2637,7 +2644,13 @@ "error": "Failed to retrieve workspace history for the selected date.", "loading": "Loading...", "workspace": "Workspace:", - "date": "Preview date:" + "date": "Preview date:", + "searchMinChars": "Please enter at least 3 characters to search.", + "search": { + "placeholder": "Search files...", + "searching": "Searching...", + "noResults": "No results found." + } }, "fileViewers": { "openWithDefault": "Open with default app", diff --git a/client/src/locales/fr-FR.json b/client/src/locales/fr-FR.json index 115de3e21fd..5cda6a56952 100644 --- a/client/src/locales/fr-FR.json +++ b/client/src/locales/fr-FR.json @@ -852,6 +852,13 @@ "actionUnselectAll": "Tout désélectionner", "actionShare": "Partager" }, + "search": { + "placeholder": "Rechercher des fichiers...", + "searching": "Recherche en cours...", + "searchingIn": "Recherche dans {folder}", + "noResults": "Aucun résultat trouvé.", + "minChars": "Veuillez entrer au moins 3 caractères pour effectuer une recherche." + }, "importModal": { "title": "Importer vos fichiers", "dragAndDrop": "Glisser/Déposer vos fichiers ici", @@ -2636,7 +2643,13 @@ "error": "Impossible de récupérer l'historique de l'espace de travail pour la date sélectionnée", "loading": "Chargement...", "workspace": "Espace de travail :", - "date": "Date de prévisualisation :" + "date": "Date de prévisualisation :", + "searchMinChars": "Veuillez entrer au moins 3 caractères pour effectuer une recherche.", + "search": { + "placeholder": "Rechercher des fichiers...", + "searching": "Recherche en cours...", + "noResults": "Aucun résultat trouvé." + } }, "fileViewers": { "openWithDefault": "Ouvrir avec l'app par défaut", diff --git a/client/src/parsec/file.ts b/client/src/parsec/file.ts index d49788383d7..6a9345eb2b4 100644 --- a/client/src/parsec/file.ts +++ b/client/src/parsec/file.ts @@ -16,6 +16,7 @@ import { ParsedParsecAddrTag, ParsedParsecAddrWorkspacePath, Result, + SearchHandle, WorkspaceCreateFileError, WorkspaceCreateFolderError, WorkspaceFdCloseError, @@ -26,6 +27,10 @@ import { WorkspaceMoveEntryError, WorkspaceOpenFileError, WorkspaceRemoveEntryError, + WorkspaceSearchCloseError, + WorkspaceSearchError, + WorkspaceSearchGetNextError, + WorkspaceSearchMatch, WorkspaceStatEntryError, WorkspaceStatFolderChildrenError, } from '@/parsec/types'; @@ -334,3 +339,21 @@ export async function isFileContentAvailable(workspaceHandle: WorkspaceHandle, p return result.ok && result.value; } + +export async function startWorkspaceSearch( + workspaceHandle: WorkspaceHandle, + path: FsPath, + query: string, +): Promise> { + return await libparsec.workspaceSearch(workspaceHandle, path, query); +} + +export async function getNextWorkspaceSearchResult( + searchHandle: SearchHandle, +): Promise> { + return await libparsec.workspaceSearchGetNext(searchHandle); +} + +export async function closeWorkspaceSearch(searchHandle: SearchHandle): Promise> { + return await libparsec.workspaceSearchClose(searchHandle); +} diff --git a/client/src/parsec/history.ts b/client/src/parsec/history.ts index 4733e5ad65b..c0078b34c5c 100644 --- a/client/src/parsec/history.ts +++ b/client/src/parsec/history.ts @@ -5,6 +5,7 @@ import { FileDescriptor, FsPath, Result, + SearchHandle, WorkspaceHistoryEntryStat, WorkspaceHistoryEntryStatFile, WorkspaceHistoryEntryStatFolder, @@ -18,6 +19,11 @@ import { WorkspaceHistoryInternalOnlyErrorTag, WorkspaceHistoryOpenFileError, WorkspaceHistoryOpenFileErrorTag, + WorkspaceHistorySearchCloseError, + WorkspaceHistorySearchError, + WorkspaceHistorySearchErrorTag, + WorkspaceHistorySearchGetNextError, + WorkspaceHistorySearchMatch, WorkspaceHistorySetTimestampOfInterestError, WorkspaceHistorySetTimestampOfInterestErrorTag, WorkspaceHistoryStartError, @@ -198,6 +204,21 @@ export class WorkspaceHistory { } return await libparsec.workspaceHistoryFdRead(this.handle, fd, BigInt(offset), BigInt(size)); } + + async search(path: FsPath, query: string): Promise> { + if (this.handle === undefined) { + return { ok: false, error: { tag: WorkspaceHistorySearchErrorTag.Internal, error: 'Not started' } }; + } + return await libparsec.workspaceHistorySearch(this.handle, path, query); + } + + async searchGetNext(searchHandle: SearchHandle): Promise> { + return await libparsec.workspaceHistorySearchGetNext(searchHandle); + } + + async searchClose(searchHandle: SearchHandle): Promise> { + return await libparsec.workspaceHistorySearchClose(searchHandle); + } } export interface HistoryEntryTree { diff --git a/client/src/parsec/types.ts b/client/src/parsec/types.ts index 34fd69d103f..027992ed7dc 100644 --- a/client/src/parsec/types.ts +++ b/client/src/parsec/types.ts @@ -88,6 +88,9 @@ export { WorkspaceHistoryFdReadErrorTag, WorkspaceHistoryInternalOnlyErrorTag, WorkspaceHistoryOpenFileErrorTag, + WorkspaceHistorySearchCloseErrorTag, + WorkspaceHistorySearchErrorTag, + WorkspaceHistorySearchGetNextErrorTag, WorkspaceHistorySetTimestampOfInterestErrorTag, WorkspaceHistoryStatEntryErrorTag, WorkspaceHistoryStatFolderChildrenErrorTag, @@ -97,6 +100,9 @@ export { WorkspaceMoveEntryErrorTag, WorkspaceOpenFileErrorTag, WorkspaceRemoveEntryErrorTag, + WorkspaceSearchCloseErrorTag, + WorkspaceSearchErrorTag, + WorkspaceSearchGetNextErrorTag, WorkspaceStatEntryErrorTag, WorkspaceStatFolderChildrenErrorTag, WorkspaceStopErrorTag, @@ -259,6 +265,10 @@ export type { WorkspaceHistoryFdReadError, WorkspaceHistoryInternalOnlyError, WorkspaceHistoryOpenFileError, + WorkspaceHistorySearchCloseError, + WorkspaceHistorySearchError, + WorkspaceHistorySearchGetNextError, + WorkspaceHistorySearchMatch, WorkspaceHistorySetTimestampOfInterestError, WorkspaceHistoryStartError, WorkspaceHistoryStatEntryError, @@ -270,6 +280,10 @@ export type { WorkspaceMoveEntryError, WorkspaceOpenFileError, WorkspaceRemoveEntryError, + WorkspaceSearchCloseError, + WorkspaceSearchError, + WorkspaceSearchGetNextError, + WorkspaceSearchMatch, WorkspaceStatEntryError, WorkspaceStatFolderChildrenError, WorkspaceStopError, @@ -307,6 +321,7 @@ import { InvitationType, RealmRole as WorkspaceRole } from '@/plugins/libparsec' type WorkspaceHistoryHandle = Handle; type WorkspaceHandle = Handle; +type SearchHandle = Handle; type EntryID = VlobID; type WorkspaceName = EntryName; type ConnectionHandle = Handle; @@ -490,6 +505,7 @@ export { OwnDeviceInfo, ParsecOrganizationAddr, RegistrationDevice, + SearchHandle, StartedWorkspaceInfo, SystemPath, UserID, diff --git a/client/src/views/files/FoldersPage.vue b/client/src/views/files/FoldersPage.vue index 3510be563f3..d455778037d 100644 --- a/client/src/views/files/FoldersPage.vue +++ b/client/src/views/files/FoldersPage.vue @@ -11,6 +11,13 @@ v-if="isLargeDisplay" :buttons="actionBarOptionsFoldersPage" > +
@@ -104,7 +111,22 @@
+ {{ $msTranslate('FoldersPage.search.minChars') }} +
+ + + +
@@ -204,6 +226,7 @@ import { MsOptions, MsReportText, MsReportTheme, + MsSearchInput, MsSorter, MsSorterChangeEvent, MsSpinner, @@ -230,6 +253,7 @@ import { FoldersPageSavedData, ImportType, SortProperty, + WorkspaceSearchResultList, copyPathLinkToClipboard, selectFolder, } from '@/components/files'; @@ -241,16 +265,22 @@ import { ClientInfo, EntryName, EntryStatFile, + FileType, FsPath, Path, + SearchHandle, WorkspaceCreateFolderErrorTag, WorkspaceID, WorkspaceRole, + WorkspaceSearchMatch, + closeWorkspaceSearch, entryStat, getClientInfo, + getNextWorkspaceSearchResult, isDesktop, isWeb, listWorkspaces, + startWorkspaceSearch, } from '@/parsec'; import { Routes, currentRouteIs, getCurrentRouteQuery, getDocumentPath, getWorkspaceHandle, navigateTo, watchRoute } from '@/router'; import { isFileEditable } from '@/services/cryptpad'; @@ -447,6 +477,20 @@ const fileGridDisplayRef = useTemplateRef>( const fileInputsRef = useTemplateRef>('fileInputs'); let eventCbId: string | null = null; +const searchQuery = ref(''); +const searchResults = ref([]); +const isSearching = ref(false); +let currentSearchHandle: SearchHandle | null = null; + +watch(searchQuery, (newQuery) => { + searchResults.value = []; + if (newQuery) { + isSearching.value = true; + } else { + isSearching.value = false; + } +}); + const selectedFilesCount = computed(() => { return files.value.selectedCount() + folders.value.selectedCount(); }); @@ -709,6 +753,7 @@ onUnmounted(async () => { if (eventCbId) { eventDistributor.value.removeCallback(eventCbId); } + await cancelCurrentSearch(); }); async function updateWorkspaceInfo(workspaceId: WorkspaceID): Promise { @@ -1514,6 +1559,72 @@ async function onDropAsReader(): Promise { ); } +async function cancelCurrentSearch(): Promise { + if (currentSearchHandle !== null) { + const handle = currentSearchHandle; + currentSearchHandle = null; + await closeWorkspaceSearch(handle); + } +} + +async function onSearchChange(query: string): Promise { + await cancelCurrentSearch(); + searchResults.value = []; + + if (!query || !workspaceInfo.value) { + isSearching.value = false; + return; + } + + isSearching.value = true; + const startResult = await startWorkspaceSearch(workspaceInfo.value.handle, currentPath.value, query); + if (!startResult.ok) { + isSearching.value = false; + return; + } + + const handle = startResult.value; + currentSearchHandle = handle; + + while (currentSearchHandle === handle) { + const nextResult = await getNextWorkspaceSearchResult(handle); + if (!nextResult.ok || nextResult.value === null) { + break; + } + if (currentSearchHandle === handle) { + searchResults.value.push(nextResult.value); + } + } + + if (currentSearchHandle === handle) { + isSearching.value = false; + currentSearchHandle = null; + await closeWorkspaceSearch(handle); + } +} + +async function onSearchResultClick(match: WorkspaceSearchMatch): Promise { + if (!workspaceInfo.value) { + return; + } + await cancelCurrentSearch(); + searchQuery.value = ''; + searchResults.value = []; + + const isFile = match.stat.tag === FileType.File; + if (isFile) { + const parentPath = await parsec.Path.parent(match.path); + const filename = await parsec.Path.filename(match.path); + navigateTo(Routes.Documents, { + query: { documentPath: parentPath, workspaceHandle: workspaceInfo.value.handle, selectFile: filename ?? undefined }, + }); + } else { + navigateTo(Routes.Documents, { + query: { documentPath: match.path, workspaceHandle: workspaceInfo.value.handle }, + }); + } +} + const actionBarOptionsFoldersPage = computed(() => { const actionArray = []; const selectedEntries = getSelectedEntries(); @@ -1714,6 +1825,17 @@ const actionBarOptionsFoldersPage = computed(() => { } } +.search-input { + flex-shrink: 1; + min-width: 10rem; + max-width: 20rem; +} + +.search-result-list { + flex-grow: 1; + overflow-y: auto; +} + .workspace-role-tag { background: var(--parsec-color-light-secondary-white); padding: 0.25rem; diff --git a/client/src/views/workspaces/WorkspaceHistoryPage.vue b/client/src/views/workspaces/WorkspaceHistoryPage.vue index 5ec880deb6e..7eb740843f8 100644 --- a/client/src/views/workspaces/WorkspaceHistoryPage.vue +++ b/client/src/views/workspaces/WorkspaceHistoryPage.vue @@ -86,22 +86,25 @@ class="folder-header__actions" ref="topbarRight" > + {{ $msTranslate('workspaceHistory.actions.selectAll') }} {{ $msTranslate('workspaceHistory.actions.deselectAll') }} -
{{ $msTranslate('workspaceHistory.loading') }}
{{ $msTranslate('workspaceHistory.empty') }}
{{ $msTranslate(error) }}
+
@@ -180,10 +191,25 @@