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:
ByteAtATime 2025-06-26 14:27:30 -07:00
parent a561534075
commit e2316cf949
No known key found for this signature in database
13 changed files with 785 additions and 6 deletions

121
src-tauri/Cargo.lock generated
View file

@ -1668,6 +1668,27 @@ dependencies = [
"rustc_version", "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]] [[package]]
name = "fixedbitset" name = "fixedbitset"
version = "0.4.2" version = "0.4.2"
@ -1772,6 +1793,15 @@ dependencies = [
"xdg", "xdg",
] ]
[[package]]
name = "fsevent-sys"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "funty" name = "funty"
version = "2.0.0" version = "2.0.0"
@ -2696,6 +2726,26 @@ dependencies = [
"cfg-if", "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]] [[package]]
name = "inout" name = "inout"
version = "0.1.4" version = "0.1.4"
@ -2884,6 +2934,26 @@ dependencies = [
"windows-sys 0.59.0", "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]] [[package]]
name = "kuchikiki" name = "kuchikiki"
version = "0.8.8-speedreader" version = "0.8.8-speedreader"
@ -2986,6 +3056,7 @@ checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d"
dependencies = [ dependencies = [
"bitflags 2.9.1", "bitflags 2.9.1",
"libc", "libc",
"redox_syscall",
] ]
[[package]] [[package]]
@ -3184,6 +3255,18 @@ dependencies = [
"simd-adler32", "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]] [[package]]
name = "mio" name = "mio"
version = "1.0.4" version = "1.0.4"
@ -3337,6 +3420,39 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" 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]] [[package]]
name = "num-bigint" name = "num-bigint"
version = "0.4.6" version = "0.4.6"
@ -4474,6 +4590,8 @@ dependencies = [
"image", "image",
"keyring", "keyring",
"lazy_static", "lazy_static",
"notify",
"notify-debouncer-full",
"once_cell", "once_cell",
"rand 0.9.1", "rand 0.9.1",
"rayon", "rayon",
@ -4501,6 +4619,7 @@ dependencies = [
"trash", "trash",
"url", "url",
"uuid", "uuid",
"walkdir",
"xkbcommon 0.8.0", "xkbcommon 0.8.0",
"zbus", "zbus",
"zip", "zip",
@ -6041,7 +6160,7 @@ dependencies = [
"backtrace", "backtrace",
"bytes", "bytes",
"libc", "libc",
"mio", "mio 1.0.4",
"parking_lot", "parking_lot",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry", "signal-hook-registry",

View file

@ -59,6 +59,9 @@ lazy_static = "1.5.0"
xkbcommon = "0.8.0" xkbcommon = "0.8.0"
tauri-plugin-dialog = "2" tauri-plugin-dialog = "2"
tauri-plugin-fs = "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] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-global-shortcut = "2" tauri-plugin-global-shortcut = "2"

View file

@ -8,7 +8,7 @@ use chrono::Utc;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use rusqlite::{params, Connection, Result as RusqliteResult}; use rusqlite::{params, Connection, Result as RusqliteResult};
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::AtomicBool;
use std::sync::Mutex; use std::sync::Mutex;
use tauri::{AppHandle, Manager}; use tauri::{AppHandle, Manager};

View file

@ -9,6 +9,7 @@ pub enum AppError {
Keyring(keyring::Error), Keyring(keyring::Error),
ClipboardHistory(String), ClipboardHistory(String),
Frecency(String), Frecency(String),
FileSearch(String),
} }
impl From<io::Error> for AppError { 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::Keyring(err) => write!(f, "Keychain error: {}", err),
AppError::ClipboardHistory(msg) => write!(f, "Clipboard history error: {}", msg), AppError::ClipboardHistory(msg) => write!(f, "Clipboard history error: {}", msg),
AppError::Frecency(msg) => write!(f, "Frecency error: {}", msg), AppError::Frecency(msg) => write!(f, "Frecency error: {}", msg),
AppError::FileSearch(msg) => write!(f, "File search error: {}", msg),
} }
} }
} }

View 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)
})
}

View 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())
}
}

View 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);
}
});
}

View 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
}

View 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(())
}

View file

@ -12,6 +12,7 @@ mod oauth;
mod quicklinks; mod quicklinks;
mod snippets; mod snippets;
mod system; mod system;
mod file_search;
use crate::snippets::input_manager::{EvdevInputManager, InputManager}; use crate::snippets::input_manager::{EvdevInputManager, InputManager};
use crate::{app::App, cache::AppCache}; 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| { .with_handler(move |_app, shortcut, event| {
println!("Shortcut: {:?}, Event: {:?}", shortcut, event); println!("Shortcut: {:?}, Event: {:?}", shortcut, event);
if shortcut == &spotlight_shortcut && event.state() == ShortcutState::Pressed { 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); println!("Spotlight window: {:?}", spotlight_window);
if spotlight_window.is_visible().unwrap_or(false) { if spotlight_window.is_visible().unwrap_or(false) {
spotlight_window.hide().unwrap(); spotlight_window.hide().unwrap();
@ -245,7 +246,8 @@ pub fn run() {
snippets::delete_snippet, snippets::delete_snippet,
snippets::import_snippets, snippets::import_snippets,
snippets::paste_snippet_content, snippets::paste_snippet_content,
snippets::snippet_was_used snippets::snippet_was_used,
file_search::search_files
]) ])
.setup(|app| { .setup(|app| {
let app_handle = app.handle().clone(); let app_handle = app.handle().clone();
@ -265,6 +267,9 @@ pub fn run() {
snippet_manager.init_db()?; snippet_manager.init_db()?;
app.manage(snippet_manager); 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_background_refresh();
setup_global_shortcut(app)?; setup_global_shortcut(app)?;
setup_input_listener(app.handle()); setup_input_listener(app.handle());

View 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>

View file

@ -12,7 +12,8 @@ export type ViewState =
| 'search-snippets' | 'search-snippets'
| 'quicklink-form' | 'quicklink-form'
| 'create-snippet-form' | 'create-snippet-form'
| 'import-snippets'; | 'import-snippets'
| 'file-search';
type OauthState = { type OauthState = {
url: string; url: string;
@ -65,6 +66,10 @@ class ViewManager {
this.currentView = 'import-snippets'; this.currentView = 'import-snippets';
}; };
showFileSearch = () => {
this.currentView = 'file-search';
};
runPlugin = (plugin: PluginInfo) => { runPlugin = (plugin: PluginInfo) => {
switch (plugin.pluginPath) { switch (plugin.pluginPath) {
case 'builtin:store': case 'builtin:store':
@ -85,6 +90,9 @@ class ViewManager {
case 'builtin:import-snippets': case 'builtin:import-snippets':
this.showImportSnippets(); this.showImportSnippets();
return; return;
case 'builtin:file-search':
this.showFileSearch();
return;
} }
uiStore.setCurrentRunningPlugin(plugin); uiStore.setCurrentRunningPlugin(plugin);

View file

@ -16,6 +16,7 @@
import SnippetForm from '$lib/components/SnippetForm.svelte'; import SnippetForm from '$lib/components/SnippetForm.svelte';
import ImportSnippets from '$lib/components/ImportSnippets.svelte'; import ImportSnippets from '$lib/components/ImportSnippets.svelte';
import SearchSnippets from '$lib/components/SearchSnippets.svelte'; import SearchSnippets from '$lib/components/SearchSnippets.svelte';
import FileSearchView from '$lib/components/FileSearchView.svelte';
import { getCurrentWindow } from '@tauri-apps/api/window'; import { getCurrentWindow } from '@tauri-apps/api/window';
const storePlugin: PluginInfo = { const storePlugin: PluginInfo = {
@ -90,6 +91,18 @@
mode: 'view' 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 { pluginList, currentPreferences } = $derived(uiStore);
const allPlugins = $derived([ const allPlugins = $derived([
...pluginList, ...pluginList,
@ -98,7 +111,8 @@
searchSnippetsPlugin, searchSnippetsPlugin,
createQuicklinkPlugin, createQuicklinkPlugin,
createSnippetPlugin, createSnippetPlugin,
importSnippetsPlugin importSnippetsPlugin,
fileSearchPlugin
]); ]);
const { currentView, oauthState, oauthStatus, quicklinkToEdit, snippetsForImport } = const { currentView, oauthState, oauthStatus, quicklinkToEdit, snippetsForImport } =
@ -222,4 +236,6 @@
<SnippetForm onBack={viewManager.showCommandPalette} onSave={viewManager.showCommandPalette} /> <SnippetForm onBack={viewManager.showCommandPalette} onSave={viewManager.showCommandPalette} />
{:else if currentView === 'import-snippets'} {:else if currentView === 'import-snippets'}
<ImportSnippets onBack={viewManager.showCommandPalette} snippetsToImport={snippetsForImport} /> <ImportSnippets onBack={viewManager.showCommandPalette} snippetsToImport={snippetsForImport} />
{:else if currentView === 'file-search'}
<FileSearchView onBack={viewManager.showCommandPalette} />
{/if} {/if}