mirror of
https://github.com/ByteAtATime/raycast-linux.git
synced 2025-09-13 01:16:21 +00:00
feat(file-search): implement file and folder search with indexing
This commit adds a basic file search feature, allowing users to find files and folders within their home directory. The backend consists of a Rust module that builds and maintains a SQLite index of the file system. It performs an initial scan and then uses the `notify` crate to watch for real-time changes. On the frontend, a new Svelte view provides a dedicated UI for searching. It displays results, file details, and an action bar with options to open the file/folder, show it in the native file manager, copy its path, or move it to the trash.
This commit is contained in:
parent
a561534075
commit
e2316cf949
13 changed files with 785 additions and 6 deletions
121
src-tauri/Cargo.lock
generated
121
src-tauri/Cargo.lock
generated
|
@ -1668,6 +1668,27 @@ dependencies = [
|
|||
"rustc_version",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "file-id"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6bc904b9bbefcadbd8e3a9fb0d464a9b979de6324c03b3c663e8994f46a5be36"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.25"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35c0522e981e68cbfa8c3f978441a5f34b30b96e146b33cd3359176b50fe8586"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"libredox",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fixedbitset"
|
||||
version = "0.4.2"
|
||||
|
@ -1772,6 +1793,15 @@ dependencies = [
|
|||
"xdg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fsevent-sys"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "funty"
|
||||
version = "2.0.0"
|
||||
|
@ -2696,6 +2726,26 @@ dependencies = [
|
|||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8069d3ec154eb856955c1c0fbffefbf5f3c40a104ec912d4797314c1801abff"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"inotify-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inotify-sys"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "inout"
|
||||
version = "0.1.4"
|
||||
|
@ -2884,6 +2934,26 @@ dependencies = [
|
|||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a"
|
||||
dependencies = [
|
||||
"kqueue-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kqueue-sys"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kuchikiki"
|
||||
version = "0.8.8-speedreader"
|
||||
|
@ -2986,6 +3056,7 @@ checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
|
|||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"libc",
|
||||
"redox_syscall",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
@ -3184,6 +3255,18 @@ dependencies = [
|
|||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "0.8.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a4a650543ca06a924e8b371db273b2756685faae30f8487da1b56505a8f78b0c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.0.4"
|
||||
|
@ -3337,6 +3420,39 @@ version = "0.3.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8"
|
||||
|
||||
[[package]]
|
||||
name = "notify"
|
||||
version = "6.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6205bd8bb1e454ad2e27422015fb5e4f2bcc7e08fa8f27058670d208324a4d2d"
|
||||
dependencies = [
|
||||
"bitflags 2.9.1",
|
||||
"crossbeam-channel",
|
||||
"filetime",
|
||||
"fsevent-sys",
|
||||
"inotify",
|
||||
"kqueue",
|
||||
"libc",
|
||||
"log",
|
||||
"mio 0.8.11",
|
||||
"walkdir",
|
||||
"windows-sys 0.48.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "notify-debouncer-full"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb7fd166739789c9ff169e654dc1501373db9d80a4c3f972817c8a4d7cf8f34e"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"file-id",
|
||||
"log",
|
||||
"notify",
|
||||
"parking_lot",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.6"
|
||||
|
@ -4474,6 +4590,8 @@ dependencies = [
|
|||
"image",
|
||||
"keyring",
|
||||
"lazy_static",
|
||||
"notify",
|
||||
"notify-debouncer-full",
|
||||
"once_cell",
|
||||
"rand 0.9.1",
|
||||
"rayon",
|
||||
|
@ -4501,6 +4619,7 @@ dependencies = [
|
|||
"trash",
|
||||
"url",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
"xkbcommon 0.8.0",
|
||||
"zbus",
|
||||
"zip",
|
||||
|
@ -6041,7 +6160,7 @@ dependencies = [
|
|||
"backtrace",
|
||||
"bytes",
|
||||
"libc",
|
||||
"mio",
|
||||
"mio 1.0.4",
|
||||
"parking_lot",
|
||||
"pin-project-lite",
|
||||
"signal-hook-registry",
|
||||
|
|
|
@ -59,6 +59,9 @@ lazy_static = "1.5.0"
|
|||
xkbcommon = "0.8.0"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
walkdir = "2.5.0"
|
||||
notify = "6.1.1"
|
||||
notify-debouncer-full = "0.3.1"
|
||||
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-global-shortcut = "2"
|
||||
|
|
|
@ -8,7 +8,7 @@ use chrono::Utc;
|
|||
use once_cell::sync::Lazy;
|
||||
use rusqlite::{params, Connection, Result as RusqliteResult};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Mutex;
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
|
|
|
@ -9,6 +9,7 @@ pub enum AppError {
|
|||
Keyring(keyring::Error),
|
||||
ClipboardHistory(String),
|
||||
Frecency(String),
|
||||
FileSearch(String),
|
||||
}
|
||||
|
||||
impl From<io::Error> for AppError {
|
||||
|
@ -51,6 +52,7 @@ impl std::fmt::Display for AppError {
|
|||
AppError::Keyring(err) => write!(f, "Keychain error: {}", err),
|
||||
AppError::ClipboardHistory(msg) => write!(f, "Clipboard history error: {}", msg),
|
||||
AppError::Frecency(msg) => write!(f, "Frecency error: {}", msg),
|
||||
AppError::FileSearch(msg) => write!(f, "File search error: {}", msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
104
src-tauri/src/file_search/indexer.rs
Normal file
104
src-tauri/src/file_search/indexer.rs
Normal file
|
@ -0,0 +1,104 @@
|
|||
use super::{manager::FileSearchManager, types::IndexedFile};
|
||||
use std::{env, time::SystemTime};
|
||||
use tauri::{AppHandle, Manager};
|
||||
use walkdir::{DirEntry, WalkDir};
|
||||
|
||||
pub async fn build_initial_index(app_handle: AppHandle) {
|
||||
println!("Starting initial file index build.");
|
||||
let manager = app_handle.state::<FileSearchManager>();
|
||||
let home_dir = match env::var("HOME") {
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to get home directory: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let walker = WalkDir::new(home_dir).into_iter();
|
||||
for entry in walker.filter_entry(|e| !is_hidden(e) && !is_excluded(e)) {
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(e) => {
|
||||
eprintln!("Error walking directory: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let path = entry.path();
|
||||
let metadata = match entry.metadata() {
|
||||
Ok(meta) => meta,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let last_modified_secs = metadata
|
||||
.modified()
|
||||
.unwrap_or(SystemTime::UNIX_EPOCH)
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
|
||||
if let Ok(Some(indexed_time)) = manager.get_file_last_modified(&path.to_string_lossy()) {
|
||||
if indexed_time >= last_modified_secs {
|
||||
if path.is_dir() {
|
||||
// continue to walk children
|
||||
} else {
|
||||
// skip this file
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let file_type = if metadata.is_dir() {
|
||||
"directory".to_string()
|
||||
} else if metadata.is_file() {
|
||||
"file".to_string()
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let indexed_file = IndexedFile {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
name: entry.file_name().to_string_lossy().to_string(),
|
||||
parent_path: path
|
||||
.parent()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_default(),
|
||||
file_type,
|
||||
last_modified: last_modified_secs,
|
||||
};
|
||||
|
||||
if let Err(e) = manager.add_file(&indexed_file) {
|
||||
eprintln!("Failed to add file to index: {:?}", e);
|
||||
}
|
||||
}
|
||||
println!("Finished initial file index build.");
|
||||
}
|
||||
|
||||
fn is_hidden(entry: &DirEntry) -> bool {
|
||||
entry
|
||||
.file_name()
|
||||
.to_str()
|
||||
.map(|s| s.starts_with('.'))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn is_excluded(entry: &DirEntry) -> bool {
|
||||
let path = entry.path();
|
||||
let excluded_dirs = [
|
||||
"node_modules",
|
||||
".git",
|
||||
"target",
|
||||
".vscode",
|
||||
".idea",
|
||||
"__pycache__",
|
||||
".cache",
|
||||
"Library",
|
||||
"Application Support",
|
||||
"AppData",
|
||||
];
|
||||
path.components().any(|component| {
|
||||
excluded_dirs
|
||||
.iter()
|
||||
.any(|&excluded| component.as_os_str() == excluded)
|
||||
})
|
||||
}
|
145
src-tauri/src/file_search/manager.rs
Normal file
145
src-tauri/src/file_search/manager.rs
Normal file
|
@ -0,0 +1,145 @@
|
|||
use std::fs;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension, Result as RusqliteResult};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use super::types::IndexedFile;
|
||||
use crate::error::AppError;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct FileSearchManager {
|
||||
db: Arc<Mutex<Connection>>,
|
||||
}
|
||||
|
||||
impl FileSearchManager {
|
||||
pub fn new(app_handle: AppHandle) -> Result<Self, AppError> {
|
||||
let data_dir = app_handle
|
||||
.path()
|
||||
.app_local_data_dir()
|
||||
.map_err(|_| AppError::DirectoryNotFound)?;
|
||||
|
||||
if !data_dir.exists() {
|
||||
fs::create_dir_all(&data_dir).map_err(|e| AppError::FileSearch(e.to_string()))?;
|
||||
}
|
||||
|
||||
let db_path = data_dir.join("file_search.sqlite");
|
||||
let db = Connection::open(db_path)?;
|
||||
|
||||
Ok(Self {
|
||||
db: Arc::new(Mutex::new(db)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn init_db(&self) -> RusqliteResult<()> {
|
||||
let db = self.db.lock().unwrap();
|
||||
|
||||
db.execute(
|
||||
"CREATE TABLE IF NOT EXISTS file_index (
|
||||
path TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
parent_path TEXT NOT NULL,
|
||||
file_type TEXT NOT NULL,
|
||||
last_modified INTEGER NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
db.execute(
|
||||
"CREATE VIRTUAL TABLE IF NOT EXISTS file_index_fts
|
||||
USING fts5(name, content='file_index', content_rowid='rowid', tokenize = 'porter unicode61')",
|
||||
[],
|
||||
)?;
|
||||
|
||||
db.execute(
|
||||
"CREATE TRIGGER IF NOT EXISTS file_index_after_insert
|
||||
AFTER INSERT ON file_index
|
||||
BEGIN
|
||||
INSERT INTO file_index_fts(rowid, name) VALUES (new.rowid, new.name);
|
||||
END;",
|
||||
[],
|
||||
)?;
|
||||
|
||||
db.execute(
|
||||
"CREATE TRIGGER IF NOT EXISTS file_index_after_delete
|
||||
AFTER DELETE ON file_index
|
||||
BEGIN
|
||||
INSERT INTO file_index_fts(file_index_fts, rowid, name) VALUES ('delete', old.rowid, old.name);
|
||||
END;",
|
||||
[],
|
||||
)?;
|
||||
|
||||
db.execute(
|
||||
"CREATE TRIGGER IF NOT EXISTS file_index_after_update
|
||||
AFTER UPDATE ON file_index
|
||||
BEGIN
|
||||
INSERT INTO file_index_fts(file_index_fts, rowid, name) VALUES ('delete', old.rowid, old.name);
|
||||
INSERT INTO file_index_fts(rowid, name) VALUES (new.rowid, new.name);
|
||||
END;",
|
||||
[],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_file(&self, file: &IndexedFile) -> Result<(), AppError> {
|
||||
let db = self.db.lock().unwrap();
|
||||
db.execute(
|
||||
"INSERT OR REPLACE INTO file_index (path, name, parent_path, file_type, last_modified)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![
|
||||
file.path,
|
||||
file.name,
|
||||
file.parent_path,
|
||||
file.file_type,
|
||||
file.last_modified
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn remove_file(&self, path: &str) -> Result<(), AppError> {
|
||||
let db = self.db.lock().unwrap();
|
||||
db.execute("DELETE FROM file_index WHERE path = ?1", params![path])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_file_last_modified(&self, path: &str) -> Result<Option<i64>, AppError> {
|
||||
let db = self.db.lock().unwrap();
|
||||
let last_modified: Result<Option<i64>, rusqlite::Error> = db
|
||||
.query_row(
|
||||
"SELECT last_modified FROM file_index WHERE path = ?1",
|
||||
params![path],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.optional();
|
||||
|
||||
Ok(last_modified?)
|
||||
}
|
||||
|
||||
pub fn search_files(&self, term: &str, limit: u32) -> Result<Vec<IndexedFile>, AppError> {
|
||||
let db = self.db.lock().unwrap();
|
||||
let mut stmt = db.prepare(
|
||||
"SELECT t1.path, t1.name, t1.parent_path, t1.file_type, t1.last_modified
|
||||
FROM file_index t1 JOIN file_index_fts t2 ON t1.rowid = t2.rowid
|
||||
WHERE t2.name MATCH ?1
|
||||
ORDER BY t1.last_modified DESC
|
||||
LIMIT ?2",
|
||||
)?;
|
||||
|
||||
let search_term = format!("\"{}\"*", term);
|
||||
let files_iter = stmt.query_map(params![search_term, limit], |row| {
|
||||
Ok(IndexedFile {
|
||||
path: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
parent_path: row.get(2)?,
|
||||
file_type: row.get(3)?,
|
||||
last_modified: row.get(4)?,
|
||||
})
|
||||
})?;
|
||||
|
||||
files_iter
|
||||
.collect::<RusqliteResult<Vec<_>>>()
|
||||
.map_err(|e| e.into())
|
||||
}
|
||||
}
|
44
src-tauri/src/file_search/mod.rs
Normal file
44
src-tauri/src/file_search/mod.rs
Normal file
|
@ -0,0 +1,44 @@
|
|||
pub mod indexer;
|
||||
pub mod manager;
|
||||
pub mod types;
|
||||
pub mod watcher;
|
||||
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
use manager::FileSearchManager;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn search_files(
|
||||
term: String,
|
||||
manager: State<FileSearchManager>,
|
||||
) -> Result<Vec<types::IndexedFile>, String> {
|
||||
manager.search_files(&term, 100).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn init(app_handle: AppHandle) {
|
||||
let file_search_manager = match FileSearchManager::new(app_handle.clone()) {
|
||||
Ok(manager) => manager,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to create FileSearchManager: {:?}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = file_search_manager.init_db() {
|
||||
eprintln!("Failed to initialize file search database: {:?}", e);
|
||||
return;
|
||||
}
|
||||
|
||||
app_handle.manage(file_search_manager);
|
||||
|
||||
let indexer_handle = app_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
indexer::build_initial_index(indexer_handle).await;
|
||||
});
|
||||
|
||||
let watcher_handle = app_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(e) = watcher::start_watching(watcher_handle).await {
|
||||
eprintln!("Failed to start file watcher: {:?}", e);
|
||||
}
|
||||
});
|
||||
}
|
11
src-tauri/src/file_search/types.rs
Normal file
11
src-tauri/src/file_search/types.rs
Normal file
|
@ -0,0 +1,11 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct IndexedFile {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
pub parent_path: String,
|
||||
pub file_type: String, // "file", "directory"
|
||||
pub last_modified: i64, // unix timestamp
|
||||
}
|
97
src-tauri/src/file_search/watcher.rs
Normal file
97
src-tauri/src/file_search/watcher.rs
Normal file
|
@ -0,0 +1,97 @@
|
|||
use super::{manager::FileSearchManager, types::IndexedFile};
|
||||
use crate::error::AppError;
|
||||
use notify::{RecursiveMode, Watcher};
|
||||
use notify_debouncer_full::{new_debouncer, DebounceEventResult, DebouncedEvent};
|
||||
use std::{
|
||||
env,
|
||||
path::PathBuf,
|
||||
time::{Duration, SystemTime},
|
||||
};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
async fn handle_event(app_handle: AppHandle, debounced_event: DebouncedEvent) {
|
||||
let manager = app_handle.state::<FileSearchManager>();
|
||||
let path = &debounced_event.event.paths[0];
|
||||
|
||||
if path.exists() {
|
||||
if let Ok(metadata) = path.metadata() {
|
||||
let file_type = if metadata.is_dir() {
|
||||
"directory".to_string()
|
||||
} else {
|
||||
"file".to_string()
|
||||
};
|
||||
let last_modified = metadata
|
||||
.modified()
|
||||
.unwrap_or(SystemTime::UNIX_EPOCH)
|
||||
.duration_since(SystemTime::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs() as i64;
|
||||
|
||||
let indexed_file = IndexedFile {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
name: path
|
||||
.file_name()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_default(),
|
||||
parent_path: path
|
||||
.parent()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_default(),
|
||||
file_type,
|
||||
last_modified,
|
||||
};
|
||||
if let Err(e) = manager.add_file(&indexed_file) {
|
||||
eprintln!(
|
||||
"Failed to add/update file in index: {:?}, path: {}",
|
||||
e,
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if let Err(e) = manager.remove_file(&path.to_string_lossy()) {
|
||||
eprintln!(
|
||||
"Failed to remove file from index: {:?}, path: {}",
|
||||
e,
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_watching(app_handle: AppHandle) -> Result<(), AppError> {
|
||||
let home_dir = env::var("HOME").map_err(|e| AppError::FileSearch(e.to_string()))?;
|
||||
let app_handle_clone = app_handle.clone();
|
||||
|
||||
let mut debouncer = new_debouncer(
|
||||
Duration::from_secs(2),
|
||||
None,
|
||||
move |result: DebounceEventResult| {
|
||||
let app_handle_clone2 = app_handle_clone.clone();
|
||||
match result {
|
||||
Ok(events) => {
|
||||
for event in events {
|
||||
tauri::async_runtime::spawn(handle_event(app_handle_clone2.clone(), event));
|
||||
}
|
||||
}
|
||||
Err(errors) => {
|
||||
for error in errors {
|
||||
eprintln!("watch error: {:?}", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.map_err(|e| AppError::FileSearch(e.to_string()))?;
|
||||
|
||||
debouncer
|
||||
.watcher()
|
||||
.watch(&PathBuf::from(&home_dir), RecursiveMode::Recursive)
|
||||
.map_err(|e| AppError::FileSearch(e.to_string()))?;
|
||||
|
||||
debouncer
|
||||
.cache()
|
||||
.add_root(&PathBuf::from(&home_dir), RecursiveMode::Recursive);
|
||||
|
||||
app_handle.manage(debouncer);
|
||||
|
||||
Ok(())
|
||||
}
|
|
@ -12,6 +12,7 @@ mod oauth;
|
|||
mod quicklinks;
|
||||
mod snippets;
|
||||
mod system;
|
||||
mod file_search;
|
||||
|
||||
use crate::snippets::input_manager::{EvdevInputManager, InputManager};
|
||||
use crate::{app::App, cache::AppCache};
|
||||
|
@ -140,7 +141,7 @@ fn setup_global_shortcut(app: &mut tauri::App) -> Result<(), Box<dyn std::error:
|
|||
.with_handler(move |_app, shortcut, event| {
|
||||
println!("Shortcut: {:?}, Event: {:?}", shortcut, event);
|
||||
if shortcut == &spotlight_shortcut && event.state() == ShortcutState::Pressed {
|
||||
let spotlight_window = handle.get_webview_window("raycast-linux").unwrap();
|
||||
let spotlight_window = handle.get_webview_window("main").unwrap();
|
||||
println!("Spotlight window: {:?}", spotlight_window);
|
||||
if spotlight_window.is_visible().unwrap_or(false) {
|
||||
spotlight_window.hide().unwrap();
|
||||
|
@ -245,7 +246,8 @@ pub fn run() {
|
|||
snippets::delete_snippet,
|
||||
snippets::import_snippets,
|
||||
snippets::paste_snippet_content,
|
||||
snippets::snippet_was_used
|
||||
snippets::snippet_was_used,
|
||||
file_search::search_files
|
||||
])
|
||||
.setup(|app| {
|
||||
let app_handle = app.handle().clone();
|
||||
|
@ -265,6 +267,9 @@ pub fn run() {
|
|||
snippet_manager.init_db()?;
|
||||
app.manage(snippet_manager);
|
||||
|
||||
let app_handle_for_file_search = app.handle().clone();
|
||||
file_search::init(app_handle_for_file_search);
|
||||
|
||||
setup_background_refresh();
|
||||
setup_global_shortcut(app)?;
|
||||
setup_input_listener(app.handle());
|
||||
|
|
225
src/lib/components/FileSearchView.svelte
Normal file
225
src/lib/components/FileSearchView.svelte
Normal file
|
@ -0,0 +1,225 @@
|
|||
<script lang="ts">
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import { untrack } from 'svelte';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { ArrowLeft, Trash, Loader2, Folder, File, Copy, ArrowUpRight, Eye } from '@lucide/svelte';
|
||||
import ListItemBase from './nodes/shared/ListItemBase.svelte';
|
||||
import { writeText } from '@tauri-apps/plugin-clipboard-manager';
|
||||
import { Kbd } from './ui/kbd';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { shortcutToText } from '$lib/renderKey';
|
||||
import ActionBar from './nodes/shared/ActionBar.svelte';
|
||||
import ActionMenu from './nodes/shared/ActionMenu.svelte';
|
||||
import BaseList from './BaseList.svelte';
|
||||
import { open } from '@tauri-apps/plugin-shell';
|
||||
|
||||
type Props = {
|
||||
onBack: () => void;
|
||||
};
|
||||
|
||||
type IndexedFile = {
|
||||
path: string;
|
||||
name: string;
|
||||
parentPath: string;
|
||||
fileType: 'file' | 'directory';
|
||||
lastModified: number; // unix timestamp
|
||||
};
|
||||
|
||||
let { onBack }: Props = $props();
|
||||
|
||||
let searchResults = $state<IndexedFile[]>([]);
|
||||
let selectedIndex = $state(0);
|
||||
let searchText = $state('');
|
||||
let isFetching = $state(false);
|
||||
|
||||
const selectedItem = $derived(searchResults[selectedIndex]);
|
||||
|
||||
const fetchFiles = async () => {
|
||||
if (isFetching) return;
|
||||
isFetching = true;
|
||||
try {
|
||||
const newItems = await invoke<IndexedFile[]>('search_files', {
|
||||
term: searchText
|
||||
});
|
||||
searchResults = newItems;
|
||||
if (selectedIndex >= newItems.length) {
|
||||
selectedIndex = 0;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch files:', e);
|
||||
} finally {
|
||||
isFetching = false;
|
||||
}
|
||||
};
|
||||
|
||||
const formatDateTime = (timestamp: number) => {
|
||||
const date = new Date(timestamp * 1000);
|
||||
if (date.getFullYear() < 1971) return 'N/A';
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
const handleOpen = async (item: IndexedFile) => {
|
||||
await open(item.path);
|
||||
onBack();
|
||||
};
|
||||
|
||||
const handleShow = async (item: IndexedFile) => {
|
||||
await invoke('show_in_finder', { path: item.path });
|
||||
};
|
||||
|
||||
const handleCopyPath = async (item: IndexedFile) => {
|
||||
await writeText(item.path);
|
||||
};
|
||||
|
||||
const handleDelete = async (item: IndexedFile) => {
|
||||
await invoke('trash', { paths: [item.path] });
|
||||
fetchFiles();
|
||||
};
|
||||
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onBack();
|
||||
return;
|
||||
}
|
||||
if (!selectedItem) return;
|
||||
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 'c') {
|
||||
e.preventDefault();
|
||||
handleCopyPath(selectedItem);
|
||||
}
|
||||
|
||||
if (e.ctrlKey && e.key.toLowerCase() === 'x') {
|
||||
e.preventDefault();
|
||||
handleDelete(selectedItem);
|
||||
}
|
||||
|
||||
if (e.metaKey && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
handleShow(selectedItem);
|
||||
}
|
||||
};
|
||||
|
||||
$effect(() => {
|
||||
const term = searchText;
|
||||
if (!term) {
|
||||
searchResults = [];
|
||||
isFetching = false;
|
||||
return;
|
||||
}
|
||||
|
||||
untrack(() => {
|
||||
const timer = setTimeout(() => {
|
||||
if (term === searchText) {
|
||||
fetchFiles();
|
||||
}
|
||||
}, 200);
|
||||
return () => clearTimeout(timer);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<main class="bg-background text-foreground flex h-screen flex-col">
|
||||
<header class="flex h-12 shrink-0 items-center border-b px-2">
|
||||
<Button variant="ghost" size="icon" onclick={onBack}>
|
||||
<ArrowLeft class="size-5" />
|
||||
</Button>
|
||||
<Input
|
||||
class="rounded-none border-none !bg-transparent pr-0"
|
||||
placeholder="Search for files and folders..."
|
||||
bind:value={searchText}
|
||||
autofocus
|
||||
/>
|
||||
</header>
|
||||
<div class="grid grow grid-cols-[minmax(0,_1.5fr)_minmax(0,_2.5fr)] overflow-y-hidden">
|
||||
<div class="flex-grow overflow-y-auto border-r">
|
||||
{#if isFetching && searchResults.length === 0}
|
||||
<div class="text-muted-foreground flex h-full items-center justify-center">
|
||||
<Loader2 class="size-6 animate-spin" />
|
||||
</div>
|
||||
{/if}
|
||||
<BaseList
|
||||
items={searchResults.map((item) => ({ ...item, id: item.path }))}
|
||||
bind:selectedIndex
|
||||
onenter={(item) => handleOpen(item)}
|
||||
>
|
||||
{#snippet itemSnippet({ item, isSelected, onclick })}
|
||||
<button class="w-full text-left" {onclick}>
|
||||
<ListItemBase
|
||||
icon={item.fileType === 'directory' ? 'folder-16' : 'blank-document-16'}
|
||||
title={item.name}
|
||||
subtitle={item.parentPath}
|
||||
{isSelected}
|
||||
/>
|
||||
</button>
|
||||
{/snippet}
|
||||
</BaseList>
|
||||
</div>
|
||||
<div class="flex flex-col overflow-y-hidden">
|
||||
{#if selectedItem}
|
||||
<div class="flex h-full flex-col items-center justify-center p-4">
|
||||
<div class="mb-4">
|
||||
{#if selectedItem.fileType === 'directory'}
|
||||
<Folder class="size-24 text-gray-500" />
|
||||
{:else}
|
||||
<File class="size-24 text-gray-500" />
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-xl font-semibold">{selectedItem.name}</p>
|
||||
<p class="text-muted-foreground text-sm">{selectedItem.path}</p>
|
||||
</div>
|
||||
|
||||
<div class="border-t p-4">
|
||||
<h3 class="text-muted-foreground mb-2 text-xs font-semibold uppercase">Information</h3>
|
||||
<div class="flex flex-col gap-3 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Type</span>
|
||||
<span class="capitalize">{selectedItem.fileType}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">Last Modified</span>
|
||||
<span>{formatDateTime(selectedItem.lastModified)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ActionBar>
|
||||
{#snippet primaryAction({ props })}
|
||||
<Button {...props} onclick={() => handleOpen(selectedItem)}>
|
||||
Open <Kbd>⏎</Kbd>
|
||||
</Button>
|
||||
{/snippet}
|
||||
{#snippet actions()}
|
||||
<ActionMenu>
|
||||
<DropdownMenu.Item onclick={() => handleShow(selectedItem)}>
|
||||
<Eye class="mr-2 size-4" />
|
||||
<span>Show in File Manager</span>
|
||||
<DropdownMenu.Shortcut>
|
||||
{shortcutToText({ key: 'Enter', modifiers: ['cmd'] })}
|
||||
</DropdownMenu.Shortcut>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item onclick={() => handleCopyPath(selectedItem)}>
|
||||
<Copy class="mr-2 size-4" />
|
||||
<span>Copy Path</span>
|
||||
<DropdownMenu.Shortcut>
|
||||
{shortcutToText({ key: 'c', modifiers: ['ctrl'] })}
|
||||
</DropdownMenu.Shortcut>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item class="text-red-500" onclick={() => handleDelete(selectedItem)}>
|
||||
<Trash class="mr-2 size-4" />
|
||||
<span>Move to Trash</span>
|
||||
<DropdownMenu.Shortcut>
|
||||
{shortcutToText({ key: 'x', modifiers: ['ctrl'] })}
|
||||
</DropdownMenu.Shortcut>
|
||||
</DropdownMenu.Item>
|
||||
</ActionMenu>
|
||||
{/snippet}
|
||||
</ActionBar>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
|
@ -12,7 +12,8 @@ export type ViewState =
|
|||
| 'search-snippets'
|
||||
| 'quicklink-form'
|
||||
| 'create-snippet-form'
|
||||
| 'import-snippets';
|
||||
| 'import-snippets'
|
||||
| 'file-search';
|
||||
|
||||
type OauthState = {
|
||||
url: string;
|
||||
|
@ -65,6 +66,10 @@ class ViewManager {
|
|||
this.currentView = 'import-snippets';
|
||||
};
|
||||
|
||||
showFileSearch = () => {
|
||||
this.currentView = 'file-search';
|
||||
};
|
||||
|
||||
runPlugin = (plugin: PluginInfo) => {
|
||||
switch (plugin.pluginPath) {
|
||||
case 'builtin:store':
|
||||
|
@ -85,6 +90,9 @@ class ViewManager {
|
|||
case 'builtin:import-snippets':
|
||||
this.showImportSnippets();
|
||||
return;
|
||||
case 'builtin:file-search':
|
||||
this.showFileSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
uiStore.setCurrentRunningPlugin(plugin);
|
||||
|
|
|
@ -16,6 +16,7 @@
|
|||
import SnippetForm from '$lib/components/SnippetForm.svelte';
|
||||
import ImportSnippets from '$lib/components/ImportSnippets.svelte';
|
||||
import SearchSnippets from '$lib/components/SearchSnippets.svelte';
|
||||
import FileSearchView from '$lib/components/FileSearchView.svelte';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
|
||||
const storePlugin: PluginInfo = {
|
||||
|
@ -90,6 +91,18 @@
|
|||
mode: 'view'
|
||||
};
|
||||
|
||||
const fileSearchPlugin: PluginInfo = {
|
||||
title: 'Search Files',
|
||||
description: 'Find files and folders on your computer',
|
||||
pluginTitle: 'File Search',
|
||||
pluginName: 'File Search',
|
||||
commandName: 'index',
|
||||
pluginPath: 'builtin:file-search',
|
||||
icon: 'search-16',
|
||||
preferences: [],
|
||||
mode: 'view'
|
||||
};
|
||||
|
||||
const { pluginList, currentPreferences } = $derived(uiStore);
|
||||
const allPlugins = $derived([
|
||||
...pluginList,
|
||||
|
@ -98,7 +111,8 @@
|
|||
searchSnippetsPlugin,
|
||||
createQuicklinkPlugin,
|
||||
createSnippetPlugin,
|
||||
importSnippetsPlugin
|
||||
importSnippetsPlugin,
|
||||
fileSearchPlugin
|
||||
]);
|
||||
|
||||
const { currentView, oauthState, oauthStatus, quicklinkToEdit, snippetsForImport } =
|
||||
|
@ -222,4 +236,6 @@
|
|||
<SnippetForm onBack={viewManager.showCommandPalette} onSave={viewManager.showCommandPalette} />
|
||||
{:else if currentView === 'import-snippets'}
|
||||
<ImportSnippets onBack={viewManager.showCommandPalette} snippetsToImport={snippetsForImport} />
|
||||
{:else if currentView === 'file-search'}
|
||||
<FileSearchView onBack={viewManager.showCommandPalette} />
|
||||
{/if}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue