mirror of
https://github.com/infinilabs/coco-app.git
synced 2025-12-16 19:47:43 +01:00
feat: file search for Linux/GNOME (#884)
This commit implements the file search extension for Linux with the GNOME desktop environment by employing the engine that powers GNOME's desktop search - Tracker. It also fixes an edge case bug that the search and exclude path configuration entries will not work. For example, say I set the search path to ["~/Dcouments"], and I have a file named "Documents_foobarbuzz" under my home directory, this file is not in the specified search path but Coco would return it because we verified this by checking string prefix. Claude Code found this when I asked it to write unit tests. Thank both tests and Claude Code.
This commit is contained in:
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -104,7 +104,7 @@ jobs:
|
||||
if: startsWith(matrix.platform, 'ubuntu-22.04')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils libtracker-sparql-3.0-dev
|
||||
|
||||
- name: Add Rust build target
|
||||
working-directory: src-tauri
|
||||
|
||||
2
.github/workflows/rust_code_check.yml
vendored
2
.github/workflows/rust_code_check.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
if: startsWith(matrix.platform, 'ubuntu-latest')
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf xdg-utils libtracker-sparql-3.0-dev
|
||||
|
||||
- name: Add pizza engine as a dependency
|
||||
working-directory: src-tauri
|
||||
|
||||
@@ -22,6 +22,8 @@ Information about release notes of Coco App is provided here.
|
||||
- feat: impl extension settings 'hide_before_open' #862
|
||||
- feat: index both en/zh_CN app names and show app name in chosen language #875
|
||||
- feat: support context menu in debug mode #882
|
||||
- feat: file search for Linux/GNOME #884
|
||||
|
||||
|
||||
### 🐛 Bug fix
|
||||
|
||||
|
||||
35
src-tauri/Cargo.lock
generated
35
src-tauri/Cargo.lock
generated
@@ -858,6 +858,7 @@ dependencies = [
|
||||
"function_name",
|
||||
"futures",
|
||||
"futures-util",
|
||||
"gio 0.20.12",
|
||||
"hostname",
|
||||
"http 1.3.1",
|
||||
"hyper 0.14.32",
|
||||
@@ -910,6 +911,7 @@ dependencies = [
|
||||
"tokio-stream",
|
||||
"tokio-tungstenite",
|
||||
"tokio-util",
|
||||
"tracker-rs",
|
||||
"tungstenite 0.24.0",
|
||||
"url",
|
||||
"walkdir",
|
||||
@@ -1730,7 +1732,7 @@ version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adef9ff8c03b1c04bc2c743e0395588001b1c8f5669cab0b300abe873b9c9227"
|
||||
dependencies = [
|
||||
"gio 0.20.9",
|
||||
"gio 0.20.12",
|
||||
"gtk",
|
||||
"objc2 0.5.2",
|
||||
"objc2-app-kit 0.2.2",
|
||||
@@ -2194,9 +2196,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "gio"
|
||||
version = "0.20.9"
|
||||
version = "0.20.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4f00c70f8029d84ea7572dd0e1aaa79e5329667b4c17f329d79ffb1e6277487"
|
||||
checksum = "8e27e276e7b6b8d50f6376ee7769a71133e80d093bdc363bd0af71664228b831"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
@@ -7017,6 +7019,33 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracker-rs"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc48e3b6e7a94b6c90b6905f157f2a875dfb5866cf19429476fd803c9c84eafc"
|
||||
dependencies = [
|
||||
"bitflags 2.9.0",
|
||||
"gio 0.20.12",
|
||||
"glib 0.20.9",
|
||||
"glib-sys 0.20.9",
|
||||
"libc",
|
||||
"tracker-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracker-sys"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8814f2bd279c6fa3eb22921a5394df6109c6ec18f805e5973fa633a4e9a57785"
|
||||
dependencies = [
|
||||
"gio-sys 0.20.9",
|
||||
"glib-sys 0.20.9",
|
||||
"gobject-sys 0.20.9",
|
||||
"libc",
|
||||
"system-deps 7.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tray-icon"
|
||||
version = "0.20.1"
|
||||
|
||||
@@ -113,6 +113,11 @@ tauri-plugin-prevent-default = "1"
|
||||
[target."cfg(target_os = \"macos\")".dependencies]
|
||||
tauri-nspanel = { git = "https://github.com/ahkohd/tauri-nspanel", branch = "v2" }
|
||||
|
||||
|
||||
[target."cfg(target_os = \"linux\")".dependencies]
|
||||
gio = "0.20.12"
|
||||
tracker-rs = "0.6.1"
|
||||
|
||||
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
|
||||
tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] }
|
||||
|
||||
|
||||
@@ -0,0 +1,286 @@
|
||||
//! File system powered by GNOME's Tracker engine.
|
||||
|
||||
use super::super::EXTENSION_ID;
|
||||
use super::super::config::FileSearchConfig;
|
||||
use super::should_be_filtered_out;
|
||||
use crate::common::document::DataSourceReference;
|
||||
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use crate::util::file::sync_get_file_icon;
|
||||
use crate::{
|
||||
common::document::{Document, OnOpened},
|
||||
extension::built_in::file_search::config::SearchBy,
|
||||
};
|
||||
use gio::Cancellable;
|
||||
use tracker::{SparqlConnection, SparqlCursor, prelude::SparqlCursorExtManual};
|
||||
|
||||
/// The service that we will connect to.
|
||||
const SERVICE_NAME: &str = "org.freedesktop.Tracker3.Miner.Files";
|
||||
|
||||
/// Tracker won't return scores when we are not using full-text seach. In that
|
||||
/// case, we use this score.
|
||||
const SCORE: f64 = 1.0;
|
||||
|
||||
/// Helper function to return different SPARQL queries depending on the different configurations.
|
||||
fn query_sparql(query_string: &str, config: &FileSearchConfig) -> String {
|
||||
match config.search_by {
|
||||
SearchBy::Name => {
|
||||
// Cannot use the inverted index as that searches for all the attributes,
|
||||
// but we only want to search the filename.
|
||||
format!(
|
||||
"SELECT nie:url(?file_item) WHERE {{ ?file_item nfo:fileName ?fileName . FILTER(regex(?fileName, '{query_string}', 'i')) }}"
|
||||
)
|
||||
}
|
||||
SearchBy::NameAndContents => {
|
||||
// Full-text search against all attributes
|
||||
// OR
|
||||
// filename search
|
||||
format!(
|
||||
"SELECT nie:url(?file_item) fts:rank(?file_item) WHERE {{ {{ ?file_item fts:match '{query_string}' }} UNION {{ ?file_item nfo:fileName ?fileName . FILTER(regex(?fileName, '{query_string}', 'i')) }} }} ORDER BY DESC fts:rank(?file_item)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Helper function to replace unsupported characters with whitespace.
|
||||
///
|
||||
/// Tracker will error out if it encounters these characters.
|
||||
///
|
||||
/// The complete list of unsupported characters is unknown and we don't know how
|
||||
/// to escape them, so let's replace them.
|
||||
fn query_string_cleanup(old: &str) -> String {
|
||||
const UNSUPPORTED_CHAR: [char; 3] = ['\'', '\n', '\\'];
|
||||
|
||||
// Using len in bytes is ok
|
||||
let mut chars = Vec::with_capacity(old.len());
|
||||
for char in old.chars() {
|
||||
if UNSUPPORTED_CHAR.contains(&char) {
|
||||
chars.push(' ');
|
||||
} else {
|
||||
chars.push(char);
|
||||
}
|
||||
}
|
||||
|
||||
chars.into_iter().collect()
|
||||
}
|
||||
|
||||
struct Query {
|
||||
conn: SparqlConnection,
|
||||
cursor: SparqlCursor,
|
||||
}
|
||||
|
||||
impl Query {
|
||||
fn new(query_string: &str, config: &FileSearchConfig) -> Result<Self, String> {
|
||||
let query_string = query_string_cleanup(query_string);
|
||||
let sparql = query_sparql(&query_string, config);
|
||||
let conn =
|
||||
SparqlConnection::bus_new(SERVICE_NAME, None, None).map_err(|e| e.to_string())?;
|
||||
let cursor = conn
|
||||
.query(&sparql, Cancellable::NONE)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(Self { conn, cursor })
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Query {
|
||||
fn drop(&mut self) {
|
||||
self.cursor.close();
|
||||
self.conn.close();
|
||||
}
|
||||
}
|
||||
|
||||
impl Iterator for Query {
|
||||
/// It yields a tuple `(file path, score)`
|
||||
type Item = Result<(String, f64), String>;
|
||||
|
||||
fn next(&mut self) -> Option<Self::Item> {
|
||||
loop {
|
||||
let has_next = match self
|
||||
.cursor
|
||||
.next(Cancellable::NONE)
|
||||
.map_err(|e| e.to_string())
|
||||
{
|
||||
Ok(has_next) => has_next,
|
||||
Err(err_str) => return Some(Err(err_str)),
|
||||
};
|
||||
|
||||
if !has_next {
|
||||
return None;
|
||||
}
|
||||
|
||||
// The first column is the URL
|
||||
let file_url_column = self.cursor.string(0);
|
||||
// It could be None (or NULL ptr if you use C), I have no clue why.
|
||||
let opt_str = file_url_column.as_ref().map(|gstr| gstr.as_str());
|
||||
|
||||
match opt_str {
|
||||
Some(url) => {
|
||||
// The returned URL has a prefix that we need to trim
|
||||
const PREFIX: &str = "file://";
|
||||
const PREFIX_LEN: usize = PREFIX.len();
|
||||
|
||||
let file_path = url[PREFIX_LEN..].to_string();
|
||||
assert!(!file_path.is_empty());
|
||||
assert_ne!(file_path, "/", "file search should not hit the root path");
|
||||
|
||||
let score = {
|
||||
// The second column is the score, this column may not
|
||||
// exist. We use SCORE if the real value is absent.
|
||||
let score_column = self.cursor.string(1);
|
||||
let opt_score_str = score_column.as_ref().map(|g_str| g_str.as_str());
|
||||
let opt_score = opt_score_str.map(|str| {
|
||||
str.parse::<f64>()
|
||||
.expect("score should be valid for type f64")
|
||||
});
|
||||
|
||||
opt_score.unwrap_or(SCORE)
|
||||
};
|
||||
|
||||
return Some(Ok((file_path, score)));
|
||||
}
|
||||
None => {
|
||||
// another try
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn hits(
|
||||
query_string: &str,
|
||||
from: usize,
|
||||
size: usize,
|
||||
config: &FileSearchConfig,
|
||||
) -> Result<Vec<(Document, f64)>, String> {
|
||||
// Special cases that will make querying faster.
|
||||
if query_string.is_empty() || size == 0 || config.search_paths.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut result_hits = Vec::with_capacity(size);
|
||||
|
||||
let need_to_skip = {
|
||||
if matches!(config.search_by, SearchBy::Name) {
|
||||
// We don't use full-text search in this case, the returned documents
|
||||
// won't be scored, the query hits won't be sorted, so processing the
|
||||
// from parameter is meaningless.
|
||||
false
|
||||
} else {
|
||||
from > 0
|
||||
}
|
||||
};
|
||||
let mut num_skipped = 0;
|
||||
let should_skip = from;
|
||||
|
||||
let query = Query::new(query_string, config)?;
|
||||
for res_entry in query {
|
||||
let (file_path, score) = res_entry?;
|
||||
|
||||
// This should be called before processing the `from` parameter.
|
||||
if should_be_filtered_out(config, &file_path, true, true, true) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Process the `from` parameter.
|
||||
if need_to_skip && num_skipped < should_skip {
|
||||
// Skip this
|
||||
num_skipped += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let icon = sync_get_file_icon(&file_path);
|
||||
let file_path_of_type_path = camino::Utf8Path::new(&file_path);
|
||||
let r#where = file_path_of_type_path
|
||||
.parent()
|
||||
.unwrap_or_else(|| {
|
||||
panic!(
|
||||
"expect path [{}] to have a parent, but it does not",
|
||||
file_path
|
||||
);
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let file_name = file_path_of_type_path.file_name().unwrap_or_else(|| {
|
||||
panic!(
|
||||
"expect path [{}] to have a file name, but it does not",
|
||||
file_path
|
||||
);
|
||||
});
|
||||
let on_opened = OnOpened::Document {
|
||||
url: file_path.to_string(),
|
||||
};
|
||||
|
||||
let doc = Document {
|
||||
id: file_path.to_string(),
|
||||
title: Some(file_name.to_string()),
|
||||
source: Some(DataSourceReference {
|
||||
r#type: Some(LOCAL_QUERY_SOURCE_TYPE.into()),
|
||||
name: Some(EXTENSION_ID.into()),
|
||||
id: Some(EXTENSION_ID.into()),
|
||||
icon: Some(String::from("font_Filesearch")),
|
||||
}),
|
||||
category: Some(r#where),
|
||||
on_opened: Some(on_opened),
|
||||
url: Some(file_path),
|
||||
icon: Some(icon.to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
result_hits.push((doc, score));
|
||||
|
||||
// Collected enough documents, return
|
||||
if result_hits.len() >= size {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result_hits)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_query_string_cleanup_basic() {
|
||||
assert_eq!(query_string_cleanup("test"), "test");
|
||||
assert_eq!(query_string_cleanup("hello world"), "hello world");
|
||||
assert_eq!(query_string_cleanup("file.txt"), "file.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_string_cleanup_unsupported_chars() {
|
||||
assert_eq!(query_string_cleanup("test'file"), "test file");
|
||||
assert_eq!(query_string_cleanup("test\nfile"), "test file");
|
||||
assert_eq!(query_string_cleanup("test\\file"), "test file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_string_cleanup_multiple_unsupported() {
|
||||
assert_eq!(query_string_cleanup("test'file\nname"), "test file name");
|
||||
assert_eq!(query_string_cleanup("test\'file"), "test file");
|
||||
assert_eq!(query_string_cleanup("\n'test"), " test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_string_cleanup_edge_cases() {
|
||||
assert_eq!(query_string_cleanup(""), "");
|
||||
assert_eq!(query_string_cleanup("'"), " ");
|
||||
assert_eq!(query_string_cleanup("\n"), " ");
|
||||
assert_eq!(query_string_cleanup("\\"), " ");
|
||||
assert_eq!(query_string_cleanup(" '\n\\ "), " ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_query_string_cleanup_mixed_content() {
|
||||
assert_eq!(
|
||||
query_string_cleanup("document's content\nwith\\backslash"),
|
||||
"document s content with backslash"
|
||||
);
|
||||
assert_eq!(
|
||||
query_string_cleanup("path/to'file\nextension\\test"),
|
||||
"path/to file extension test"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
use super::super::EXTENSION_ID;
|
||||
use super::super::config::FileSearchConfig;
|
||||
use super::super::config::SearchBy;
|
||||
use super::should_be_filtered_out;
|
||||
use crate::common::document::{DataSourceReference, Document};
|
||||
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use crate::extension::OnOpened;
|
||||
use crate::util::file::get_file_icon;
|
||||
use crate::util::file::sync_get_file_icon;
|
||||
use futures::stream::Stream;
|
||||
use futures::stream::StreamExt;
|
||||
use std::os::fd::OwnedFd;
|
||||
@@ -32,7 +33,7 @@ pub(crate) async fn hits(
|
||||
while let Some(res_file_path) = iter.next().await {
|
||||
let file_path = res_file_path.map_err(|io_err| io_err.to_string())?;
|
||||
|
||||
let icon = get_file_icon(file_path.clone()).await;
|
||||
let icon = sync_get_file_icon(&file_path);
|
||||
let file_path_of_type_path = camino::Utf8Path::new(&file_path);
|
||||
let r#where = file_path_of_type_path
|
||||
.parent()
|
||||
@@ -155,7 +156,7 @@ fn execute_mdfind_query(
|
||||
.filter(move |res_path| {
|
||||
std::future::ready({
|
||||
match res_path {
|
||||
Ok(path) => !should_be_filtered_out(&config_clone, path),
|
||||
Ok(path) => !should_be_filtered_out(&config_clone, path, false, true, true),
|
||||
Err(_) => {
|
||||
// Don't filter out Err() values
|
||||
true
|
||||
@@ -168,34 +169,3 @@ fn execute_mdfind_query(
|
||||
|
||||
Ok((iter, child))
|
||||
}
|
||||
|
||||
/// If `file_path` should be removed from the search results given the filter
|
||||
/// conditions specified in `config`.
|
||||
fn should_be_filtered_out(config: &FileSearchConfig, file_path: &str) -> bool {
|
||||
let is_excluded = config
|
||||
.exclude_paths
|
||||
.iter()
|
||||
.any(|exclude_path| file_path.starts_with(exclude_path));
|
||||
|
||||
if is_excluded {
|
||||
return true;
|
||||
}
|
||||
|
||||
let matches_file_type = if config.file_types.is_empty() {
|
||||
true
|
||||
} else {
|
||||
let path_obj = camino::Utf8Path::new(&file_path);
|
||||
if let Some(extension) = path_obj.extension() {
|
||||
config
|
||||
.file_types
|
||||
.iter()
|
||||
.any(|file_type| file_type == extension)
|
||||
} else {
|
||||
// `config.file_types` is not empty, then the search results
|
||||
// should have extensions.
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
!matches_file_type
|
||||
}
|
||||
|
||||
@@ -1,10 +1,382 @@
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux_gnome;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows;
|
||||
|
||||
// `hits()` function is platform-specific, export the corresponding impl.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(crate) use linux_gnome::hits;
|
||||
#[cfg(target_os = "macos")]
|
||||
pub(crate) use macos::hits;
|
||||
#[cfg(target_os = "windows")]
|
||||
pub(crate) use windows::hits;
|
||||
|
||||
use super::config::FileSearchConfig;
|
||||
use camino::Utf8Path;
|
||||
|
||||
/// If `file_path` should be removed from the search results given the filter
|
||||
/// conditions specified in `config`.
|
||||
pub(crate) fn should_be_filtered_out(
|
||||
config: &FileSearchConfig,
|
||||
file_path: &str,
|
||||
check_search_paths: bool,
|
||||
check_exclude_paths: bool,
|
||||
check_file_type: bool,
|
||||
) -> bool {
|
||||
let file_path = Utf8Path::new(file_path);
|
||||
|
||||
if check_search_paths {
|
||||
// search path
|
||||
let in_search_paths = config.search_paths.iter().any(|search_path| {
|
||||
let search_path = Utf8Path::new(search_path);
|
||||
file_path.starts_with(search_path)
|
||||
});
|
||||
if !in_search_paths {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if check_exclude_paths {
|
||||
// exclude path
|
||||
let is_excluded = config
|
||||
.exclude_paths
|
||||
.iter()
|
||||
.any(|exclude_path| file_path.starts_with(exclude_path));
|
||||
if is_excluded {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if check_file_type {
|
||||
// file type
|
||||
let matches_file_type = if config.file_types.is_empty() {
|
||||
true
|
||||
} else {
|
||||
let path_obj = camino::Utf8Path::new(&file_path);
|
||||
if let Some(extension) = path_obj.extension() {
|
||||
config
|
||||
.file_types
|
||||
.iter()
|
||||
.any(|file_type| file_type == extension)
|
||||
} else {
|
||||
// `config.file_types` is not empty, the hit files should have extensions.
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if !matches_file_type {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::super::config::SearchBy;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_should_be_filtered_out_with_no_check() {
|
||||
let config = FileSearchConfig {
|
||||
search_paths: vec!["/home/user/Documents".to_string()],
|
||||
exclude_paths: vec![],
|
||||
file_types: vec!["fffffff".into()],
|
||||
search_by: SearchBy::Name,
|
||||
};
|
||||
|
||||
assert!(!should_be_filtered_out(
|
||||
&config, "abbc", false, false, false
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_be_filtered_out_search_paths() {
|
||||
let config = FileSearchConfig {
|
||||
search_paths: vec![
|
||||
"/home/user/Documents".to_string(),
|
||||
"/home/user/Downloads".to_string(),
|
||||
],
|
||||
exclude_paths: vec![],
|
||||
file_types: vec![],
|
||||
search_by: SearchBy::Name,
|
||||
};
|
||||
|
||||
// Files in search paths should not be filtered
|
||||
assert!(!should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Documents/file.txt",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
assert!(!should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Downloads/image.jpg",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
assert!(!should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Documents/folder/file.txt",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
|
||||
// Files not in search paths should be filtered
|
||||
assert!(should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Pictures/photo.jpg",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
assert!(should_be_filtered_out(
|
||||
&config,
|
||||
"/tmp/tempfile",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
assert!(should_be_filtered_out(
|
||||
&config,
|
||||
"/usr/bin/ls",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_be_filtered_out_exclude_paths() {
|
||||
let config = FileSearchConfig {
|
||||
search_paths: vec!["/home/user".to_string()],
|
||||
exclude_paths: vec![
|
||||
"/home/user/Trash".to_string(),
|
||||
"/home/user/.cache".to_string(),
|
||||
],
|
||||
file_types: vec![],
|
||||
search_by: SearchBy::Name,
|
||||
};
|
||||
|
||||
// Files in search paths but not excluded should not be filtered
|
||||
assert!(!should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Documents/file.txt",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
assert!(!should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Downloads/image.jpg",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
|
||||
// Files in excluded paths should be filtered
|
||||
assert!(should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Trash/deleted_file",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
assert!(should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/.cache/temp",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
assert!(should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Trash/folder/file.txt",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_be_filtered_out_file_types() {
|
||||
let config = FileSearchConfig {
|
||||
search_paths: vec!["/home/user/Documents".to_string()],
|
||||
exclude_paths: vec![],
|
||||
file_types: vec!["txt".to_string(), "md".to_string()],
|
||||
search_by: SearchBy::Name,
|
||||
};
|
||||
|
||||
// Files with allowed extensions should not be filtered
|
||||
assert!(!should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Documents/notes.txt",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
assert!(!should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Documents/readme.md",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
|
||||
// Files with disallowed extensions should be filtered
|
||||
assert!(should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Documents/image.jpg",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
assert!(should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Documents/document.pdf",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
|
||||
// Files without extensions should be filtered when file_types is not empty
|
||||
assert!(should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Documents/file",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
assert!(should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Documents/folder",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_be_filtered_out_empty_file_types() {
|
||||
let config = FileSearchConfig {
|
||||
search_paths: vec!["/home/user/Documents".to_string()],
|
||||
exclude_paths: vec![],
|
||||
file_types: vec![],
|
||||
search_by: SearchBy::Name,
|
||||
};
|
||||
|
||||
// When file_types is empty, all file types should be allowed
|
||||
assert!(!should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Documents/file.txt",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
assert!(!should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Documents/image.jpg",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
assert!(!should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Documents/document",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
assert!(!should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Documents/folder/",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_be_filtered_out_combined_filters() {
|
||||
let config = FileSearchConfig {
|
||||
search_paths: vec!["/home/user".to_string()],
|
||||
exclude_paths: vec!["/home/user/Trash".to_string()],
|
||||
file_types: vec!["txt".to_string()],
|
||||
search_by: SearchBy::Name,
|
||||
};
|
||||
|
||||
// Should pass all filters: in search path, not excluded, and correct file type
|
||||
assert!(!should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Documents/notes.txt",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
|
||||
// Fails file type filter
|
||||
assert!(should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Documents/image.jpg",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
|
||||
// Fails exclude path filter
|
||||
assert!(should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/Trash/deleted.txt",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
|
||||
// Fails search path filter
|
||||
assert!(should_be_filtered_out(
|
||||
&config,
|
||||
"/tmp/temp.txt",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_be_filtered_out_edge_cases() {
|
||||
let config = FileSearchConfig {
|
||||
search_paths: vec!["/home/user".to_string()],
|
||||
exclude_paths: vec![],
|
||||
file_types: vec!["txt".to_string()],
|
||||
search_by: SearchBy::Name,
|
||||
};
|
||||
|
||||
// Empty path
|
||||
assert!(should_be_filtered_out(&config, "", true, true, true));
|
||||
|
||||
// Root path
|
||||
assert!(should_be_filtered_out(&config, "/", true, true, true));
|
||||
|
||||
// Path that starts with search path but continues differently
|
||||
assert!(!should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user/document.txt",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
assert!(should_be_filtered_out(
|
||||
&config,
|
||||
"/home/user_other/file.txt",
|
||||
true,
|
||||
true,
|
||||
true
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ use super::super::config::SearchBy;
|
||||
use crate::common::document::{DataSourceReference, Document};
|
||||
use crate::extension::LOCAL_QUERY_SOURCE_TYPE;
|
||||
use crate::extension::OnOpened;
|
||||
use crate::util::file::get_file_icon;
|
||||
use crate::util::file::sync_get_file_icon;
|
||||
use windows::{
|
||||
Win32::System::{
|
||||
Com::{CLSCTX_INPROC_SERVER, CoCreateInstance},
|
||||
@@ -420,7 +420,7 @@ pub(crate) async fn hits(
|
||||
// "file:C:/Users/desktop.ini" => "C:/Users/desktop.ini"
|
||||
let file_path = &item_url[ITEM_URL_PREFIX_LEN..];
|
||||
|
||||
let icon = get_file_icon(file_path.to_string()).await;
|
||||
let icon = sync_get_file_icon(file_path);
|
||||
let file_path_of_type_path = camino::Utf8Path::new(&file_path);
|
||||
let r#where = file_path_of_type_path
|
||||
.parent()
|
||||
|
||||
@@ -19,7 +19,7 @@ pub(crate) const PLUGIN_JSON_FILE: &str = r#"
|
||||
{
|
||||
"id": "File Search",
|
||||
"name": "File Search",
|
||||
"platforms": ["macos", "windows"],
|
||||
"platforms": ["macos", "windows", "linux"],
|
||||
"description": "Search files on your system",
|
||||
"icon": "font_Filesearch",
|
||||
"type": "extension"
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
pub mod ai_overview;
|
||||
pub mod application;
|
||||
pub mod calculator;
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
pub mod file_search;
|
||||
pub mod pizza_engine_runtime;
|
||||
pub mod quick_ai_access;
|
||||
@@ -173,18 +172,14 @@ pub(crate) async fn list_built_in_extensions(
|
||||
.await?,
|
||||
);
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(any(target_os = "macos", target_os = "windows"))] {
|
||||
built_in_extensions.push(
|
||||
load_built_in_extension(
|
||||
&dir,
|
||||
file_search::EXTENSION_ID,
|
||||
file_search::PLUGIN_JSON_FILE,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
}
|
||||
built_in_extensions.push(
|
||||
load_built_in_extension(
|
||||
&dir,
|
||||
file_search::EXTENSION_ID,
|
||||
file_search::PLUGIN_JSON_FILE,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
|
||||
Ok(built_in_extensions)
|
||||
}
|
||||
@@ -212,16 +207,12 @@ pub(super) async fn init_built_in_extension(
|
||||
log::debug!("built-in extension [{}] initialized", extension.id);
|
||||
}
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(any(target_os = "macos", target_os = "windows"))] {
|
||||
if extension.id == file_search::EXTENSION_ID {
|
||||
let file_system_search = file_search::FileSearchExtensionSearchSource;
|
||||
search_source_registry
|
||||
.register_source(file_system_search)
|
||||
.await;
|
||||
log::debug!("built-in extension [{}] initialized", extension.id);
|
||||
}
|
||||
}
|
||||
if extension.id == file_search::EXTENSION_ID {
|
||||
let file_system_search = file_search::FileSearchExtensionSearchSource;
|
||||
search_source_registry
|
||||
.register_source(file_system_search)
|
||||
.await;
|
||||
log::debug!("built-in extension [{}] initialized", extension.id);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -299,21 +290,17 @@ pub(crate) async fn enable_built_in_extension(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(any(target_os = "macos", target_os = "windows"))] {
|
||||
if bundle_id.extension_id == file_search::EXTENSION_ID {
|
||||
let file_system_search = file_search::FileSearchExtensionSearchSource;
|
||||
search_source_registry_tauri_state
|
||||
.register_source(file_system_search)
|
||||
.await;
|
||||
alter_extension_json_file(
|
||||
&get_built_in_extension_directory(tauri_app_handle),
|
||||
bundle_id,
|
||||
update_extension,
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if bundle_id.extension_id == file_search::EXTENSION_ID {
|
||||
let file_system_search = file_search::FileSearchExtensionSearchSource;
|
||||
search_source_registry_tauri_state
|
||||
.register_source(file_system_search)
|
||||
.await;
|
||||
alter_extension_json_file(
|
||||
&get_built_in_extension_directory(tauri_app_handle),
|
||||
bundle_id,
|
||||
update_extension,
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -387,20 +374,16 @@ pub(crate) async fn disable_built_in_extension(
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(any(target_os = "macos", target_os = "windows"))] {
|
||||
if bundle_id.extension_id == file_search::EXTENSION_ID {
|
||||
search_source_registry_tauri_state
|
||||
.remove_source(bundle_id.extension_id)
|
||||
.await;
|
||||
alter_extension_json_file(
|
||||
&get_built_in_extension_directory(tauri_app_handle),
|
||||
bundle_id,
|
||||
update_extension,
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if bundle_id.extension_id == file_search::EXTENSION_ID {
|
||||
search_source_registry_tauri_state
|
||||
.remove_source(bundle_id.extension_id)
|
||||
.await;
|
||||
alter_extension_json_file(
|
||||
&get_built_in_extension_directory(tauri_app_handle),
|
||||
bundle_id,
|
||||
update_extension,
|
||||
)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -524,17 +507,11 @@ pub(crate) async fn is_built_in_extension_enabled(
|
||||
return Ok(extension.enabled);
|
||||
}
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(any(target_os = "macos", target_os = "windows"))] {
|
||||
if bundle_id.extension_id == file_search::EXTENSION_ID
|
||||
&& bundle_id.sub_extension_id.is_none()
|
||||
{
|
||||
return Ok(search_source_registry_tauri_state
|
||||
.get_source(bundle_id.extension_id)
|
||||
.await
|
||||
.is_some());
|
||||
}
|
||||
}
|
||||
if bundle_id.extension_id == file_search::EXTENSION_ID && bundle_id.sub_extension_id.is_none() {
|
||||
return Ok(search_source_registry_tauri_state
|
||||
.get_source(bundle_id.extension_id)
|
||||
.await
|
||||
.is_some());
|
||||
}
|
||||
|
||||
unreachable!("extension [{:?}] is not a built-in extension", bundle_id)
|
||||
|
||||
@@ -170,9 +170,7 @@ pub fn run() {
|
||||
settings::get_allow_self_signature,
|
||||
assistant::ask_ai,
|
||||
crate::common::document::open,
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
extension::built_in::file_search::config::get_file_system_config,
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
extension::built_in::file_search::config::set_file_system_config,
|
||||
server::synthesize::synthesize,
|
||||
util::file::get_file_icon,
|
||||
|
||||
@@ -50,7 +50,7 @@ pub(crate) enum FileType {
|
||||
Unknown,
|
||||
}
|
||||
|
||||
async fn get_file_type(path: &str) -> FileType {
|
||||
fn get_file_type(path: &str) -> FileType {
|
||||
let path = camino::Utf8Path::new(path);
|
||||
|
||||
// stat() is more precise than file extension, use it if possible.
|
||||
@@ -167,8 +167,13 @@ fn type_to_icon(ty: FileType) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn get_file_icon(path: String) -> &'static str {
|
||||
let ty = get_file_type(path.as_str()).await;
|
||||
/// Synchronous version of `get_file_icon()`.
|
||||
pub(crate) fn sync_get_file_icon(path: &str) -> &'static str {
|
||||
let ty = get_file_type(path);
|
||||
type_to_icon(ty)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn get_file_icon(path: String) -> &'static str {
|
||||
sync_get_file_icon(&path)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user