Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
use crate::search::result_renderer::ItemHighlightState;
|
||||
use warp_core::ui::appearance::Appearance;
|
||||
use warpui::{elements::Icon, Element};
|
||||
|
||||
/// Assumes the path is a file, not a folder
|
||||
pub fn icon_from_file_path(
|
||||
path: &str,
|
||||
appearance: &Appearance,
|
||||
highlight_state: ItemHighlightState,
|
||||
) -> Box<dyn Element> {
|
||||
let icon = crate::code::icon_from_file_path(path, appearance);
|
||||
match icon {
|
||||
Some(icon) => icon,
|
||||
None => Icon::new(
|
||||
"bundled/svg/completion-file.svg",
|
||||
highlight_state.icon_fill(appearance).into_solid(),
|
||||
)
|
||||
.finish(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod icon;
|
||||
pub mod model;
|
||||
pub mod search_item;
|
||||
@@ -0,0 +1,550 @@
|
||||
#![cfg_attr(not(feature = "local_fs"), allow(dead_code))]
|
||||
use fuzzy_match::{
|
||||
contains_wildcards, match_indices_case_insensitive, match_wildcard_pattern_case_insensitive,
|
||||
FuzzyMatchResult,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
use warpui::{AppContext, Entity, ModelContext, SingletonEntity};
|
||||
|
||||
cfg_if::cfg_if! {
|
||||
if #[cfg(feature = "local_fs")] {
|
||||
use crate::workspace::ActiveSession;
|
||||
use command::blocking::Command;
|
||||
use repo_metadata::local_model::GetContentsArgs;
|
||||
use repo_metadata::wrapper_model::RepoMetadataEvent;
|
||||
use repo_metadata::RepoMetadataModel;
|
||||
use repo_metadata::repositories::DetectedRepositories;
|
||||
use std::cell::RefCell;
|
||||
use std::collections::HashMap;
|
||||
}
|
||||
}
|
||||
|
||||
use super::search_item::FileSearchResult;
|
||||
|
||||
/// Shared model for file search functionality across different UI components.
|
||||
/// This singleton provides common file discovery, fuzzy matching, and git integration.
|
||||
pub struct FileSearchModel {
|
||||
/// Cached flattened repo contents keyed by repo root path.
|
||||
/// Populated lazily on first query, invalidated when the file tree changes.
|
||||
#[cfg(feature = "local_fs")]
|
||||
repo_contents_cache: RefCell<HashMap<PathBuf, Arc<Vec<FileSearchResult>>>>,
|
||||
}
|
||||
|
||||
impl FileSearchModel {
|
||||
#[cfg_attr(not(feature = "local_fs"), allow(unused_variables))]
|
||||
pub fn new(ctx: &mut ModelContext<Self>) -> Self {
|
||||
#[cfg(feature = "local_fs")]
|
||||
ctx.subscribe_to_model(
|
||||
&RepoMetadataModel::handle(ctx),
|
||||
|me, event, _ctx| match event {
|
||||
RepoMetadataEvent::FileTreeUpdated { ids } => {
|
||||
let mut cache = me.repo_contents_cache.borrow_mut();
|
||||
for id in ids {
|
||||
if let repo_metadata::RepositoryIdentifier::Local(path) = id {
|
||||
if let Some(local) = path.to_local_path() {
|
||||
cache.remove(&local);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
RepoMetadataEvent::RepositoryRemoved { id } => {
|
||||
if let repo_metadata::RepositoryIdentifier::Local(path) = id {
|
||||
if let Some(local) = path.to_local_path() {
|
||||
me.repo_contents_cache.borrow_mut().remove(&local);
|
||||
}
|
||||
}
|
||||
}
|
||||
RepoMetadataEvent::RepositoryUpdated { id } => {
|
||||
if let repo_metadata::RepositoryIdentifier::Local(path) = id {
|
||||
if let Some(local) = path.to_local_path() {
|
||||
me.repo_contents_cache.borrow_mut().remove(&local);
|
||||
}
|
||||
}
|
||||
}
|
||||
RepoMetadataEvent::FileTreeEntryUpdated { .. }
|
||||
| RepoMetadataEvent::UpdatingRepositoryFailed { .. }
|
||||
| RepoMetadataEvent::IncrementalUpdateReady { .. } => {}
|
||||
},
|
||||
);
|
||||
|
||||
Self {
|
||||
#[cfg(feature = "local_fs")]
|
||||
repo_contents_cache: RefCell::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
pub fn repo_root(&self, _app: &AppContext) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn repo_root(&self, app: &AppContext) -> Option<PathBuf> {
|
||||
let active_window_id = app.windows().state().active_window;
|
||||
|
||||
let current_dir = active_window_id
|
||||
.and_then(|window_id| ActiveSession::as_ref(app).path_if_local(window_id))?;
|
||||
|
||||
DetectedRepositories::as_ref(app).get_root_for_path(current_dir)
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
pub fn get_folder_contents(&self, _app: &AppContext) -> Vec<FileSearchResult> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// Fetches files and folders from the filesystem at a given path. This uses the ordering generated by
|
||||
/// fs::read_dir, which is not guaranteed to be stable and in practice appears random.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn get_folder_contents(&self, app: &AppContext) -> Vec<FileSearchResult> {
|
||||
let active_window_id = app.windows().state().active_window;
|
||||
|
||||
let current_dir = match active_window_id
|
||||
.and_then(|window_id| ActiveSession::as_ref(app).path_if_local(window_id))
|
||||
{
|
||||
Some(path) => path,
|
||||
None => return Vec::new(),
|
||||
};
|
||||
|
||||
let current_dir_string = current_dir.to_string_lossy().to_string();
|
||||
|
||||
match std::fs::read_dir(current_dir) {
|
||||
Ok(entries) => entries
|
||||
.filter_map(|entry| {
|
||||
entry.ok().map(|entry| {
|
||||
let path = entry.path();
|
||||
FileSearchResult {
|
||||
path: path
|
||||
.strip_prefix(current_dir)
|
||||
.expect("path should be a descendant of current_dir")
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
project_directory: current_dir_string.clone(),
|
||||
is_directory: path.is_dir(),
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect(),
|
||||
Err(err) => {
|
||||
log::warn!("Failed to read {current_dir_string}: {err:#}");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets repository contents (files and directories) from the LocalRepoMetadataModel for the current working directory.
|
||||
/// Results are cached per repo root and invalidated when the file tree changes.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn get_repo_contents(&self, app: &AppContext) -> Arc<Vec<FileSearchResult>> {
|
||||
let Some(repo_root) = self.repo_root(app) else {
|
||||
return Arc::new(Vec::new());
|
||||
};
|
||||
|
||||
if let Some(cached) = self.repo_contents_cache.borrow().get(&repo_root) {
|
||||
return cached.clone();
|
||||
}
|
||||
|
||||
let repo_metadata = RepoMetadataModel::as_ref(app);
|
||||
let Some(id) = repo_metadata::RepositoryIdentifier::try_local(&repo_root) else {
|
||||
return Arc::new(Vec::new());
|
||||
};
|
||||
let contents = if repo_metadata.has_repository(&id, app) {
|
||||
self.get_contents_from_repo(&repo_root, repo_metadata, GetContentsArgs::default(), app)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let arc = Arc::new(contents);
|
||||
self.repo_contents_cache
|
||||
.borrow_mut()
|
||||
.insert(repo_root, arc.clone());
|
||||
arc
|
||||
}
|
||||
|
||||
/// Gets repository contents with git status information for prioritization.
|
||||
/// Reuses the cached repo contents from `get_repo_contents`.
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn get_repo_contents_with_git_status(
|
||||
&self,
|
||||
app: &AppContext,
|
||||
) -> (Arc<Vec<FileSearchResult>>, HashSet<String>) {
|
||||
let contents = self.get_repo_contents(app);
|
||||
let git_changed_files = self
|
||||
.repo_root(app)
|
||||
.and_then(|repo_root| self.get_git_changed_files(&repo_root).ok())
|
||||
.unwrap_or_default();
|
||||
(contents, git_changed_files)
|
||||
}
|
||||
|
||||
/// Gets repository contents from the LocalRepoMetadataModel for the current working directory (WASM stub)
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
pub fn get_repo_contents(&self, _app: &AppContext) -> Arc<Vec<FileSearchResult>> {
|
||||
Arc::new(Vec::new())
|
||||
}
|
||||
|
||||
/// Gets repository contents with git status information for prioritization (WASM stub)
|
||||
#[cfg(not(feature = "local_fs"))]
|
||||
pub fn get_repo_contents_with_git_status(
|
||||
&self,
|
||||
_app: &AppContext,
|
||||
) -> (Arc<Vec<FileSearchResult>>, HashSet<String>) {
|
||||
(Arc::new(Vec::new()), HashSet::new())
|
||||
}
|
||||
|
||||
/// Helper method to get repository contents from a specific repository
|
||||
#[cfg(feature = "local_fs")]
|
||||
fn get_contents_from_repo(
|
||||
&self,
|
||||
repo_path: &Path,
|
||||
repo_metadata: &repo_metadata::wrapper_model::RepoMetadataModel,
|
||||
args: GetContentsArgs,
|
||||
app: &AppContext,
|
||||
) -> Vec<FileSearchResult> {
|
||||
// Canonicalize the repository path to handle symlinks consistently
|
||||
let Ok(canonical_repo_path) = dunce::canonicalize(repo_path) else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let Some(id) = repo_metadata::RepositoryIdentifier::try_local(repo_path) else {
|
||||
return Vec::new();
|
||||
};
|
||||
if let Some(contents) = repo_metadata.get_repo_contents(&id, args, app) {
|
||||
contents
|
||||
.iter()
|
||||
.filter_map(|content| {
|
||||
match content {
|
||||
repo_metadata::RepoContent::File(file_metadata) => {
|
||||
let file_local = file_metadata.path.to_local_path_lossy();
|
||||
// Convert absolute path to relative path from the canonical repository root
|
||||
if let Ok(relative_path) = file_local.strip_prefix(&canonical_repo_path)
|
||||
{
|
||||
let path = relative_path.to_string_lossy().to_string();
|
||||
Some(FileSearchResult {
|
||||
path,
|
||||
project_directory: canonical_repo_path
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
is_directory: false,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
repo_metadata::RepoContent::Directory(dir_entry) => {
|
||||
let dir_local = dir_entry.path.to_local_path_lossy();
|
||||
// Convert absolute path to relative path from the canonical repository root
|
||||
if let Ok(relative_path) = dir_local.strip_prefix(&canonical_repo_path)
|
||||
{
|
||||
let mut path = relative_path.to_string_lossy().to_string();
|
||||
// Add trailing slash for directories using platform-specific separator
|
||||
if !path.ends_with(std::path::MAIN_SEPARATOR) {
|
||||
path.push(std::path::MAIN_SEPARATOR);
|
||||
}
|
||||
Some(FileSearchResult {
|
||||
path,
|
||||
project_directory: canonical_repo_path
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
is_directory: true,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Performs a fuzzy search on the given path with the query
|
||||
/// Prioritizes filename matches with 2x weight compared to path matches
|
||||
/// Supports space-separated queries - all parts must match for a result
|
||||
pub fn fuzzy_match_path(path: &str, query: &str) -> Option<FuzzyMatchResult> {
|
||||
if query.is_empty() {
|
||||
return Some(FuzzyMatchResult::no_match());
|
||||
}
|
||||
|
||||
// Split query by spaces to support multi-term searches
|
||||
let query_parts: Vec<&str> = query.split_whitespace().collect();
|
||||
|
||||
if query_parts.is_empty() {
|
||||
return Some(FuzzyMatchResult::no_match());
|
||||
}
|
||||
|
||||
// If there's only one query part, use the original logic
|
||||
if query_parts.len() == 1 {
|
||||
return Self::fuzzy_match_path_single(path, query_parts[0]);
|
||||
}
|
||||
|
||||
// For multiple query parts, each part must match somewhere in the path
|
||||
let mut combined_score = 0i64;
|
||||
let mut all_matched_indices = Vec::new();
|
||||
|
||||
for query_part in &query_parts {
|
||||
if let Some(part_result) = Self::fuzzy_match_path_single(path, query_part) {
|
||||
combined_score += part_result.score;
|
||||
all_matched_indices.extend(part_result.matched_indices);
|
||||
} else {
|
||||
// If any part doesn't match, the entire query fails
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
// Remove duplicates and sort indices
|
||||
all_matched_indices.sort_unstable();
|
||||
all_matched_indices.dedup();
|
||||
|
||||
Some(FuzzyMatchResult {
|
||||
score: combined_score,
|
||||
matched_indices: all_matched_indices,
|
||||
})
|
||||
}
|
||||
|
||||
/// Helper method for single-term fuzzy matching (supports wildcards)
|
||||
pub fn fuzzy_match_path_single(path: &str, query: &str) -> Option<FuzzyMatchResult> {
|
||||
if query.is_empty() {
|
||||
return Some(FuzzyMatchResult::no_match());
|
||||
}
|
||||
|
||||
// Check if query contains wildcards
|
||||
if contains_wildcards(query) {
|
||||
// Use wildcard matching for the entire path
|
||||
return match_wildcard_pattern_case_insensitive(path, query);
|
||||
}
|
||||
|
||||
// Split the path into directory and filename
|
||||
let filename = path.split('/').next_back().unwrap_or("");
|
||||
|
||||
// Try to match the filename first
|
||||
let filename_match = match_indices_case_insensitive(filename, query);
|
||||
|
||||
// Try to match the full path
|
||||
let path_match = match_indices_case_insensitive(path, query).map(|mut result| {
|
||||
result.score =
|
||||
Self::apply_path_proximity_ranking(path, &result.matched_indices, result.score);
|
||||
result
|
||||
});
|
||||
|
||||
match (filename_match, path_match) {
|
||||
(Some(filename_result), Some(path_result)) => {
|
||||
// Calculate filename offset in the full path
|
||||
let filename_start_offset = path.len() - filename.len();
|
||||
|
||||
// Check if filename had matches before consuming it
|
||||
let filename_has_matches = !filename_result.matched_indices.is_empty();
|
||||
let filename_score = filename_result.score;
|
||||
|
||||
// Check for exact filename match (without extension)
|
||||
let filename_without_ext = filename.split('.').next().unwrap_or(filename);
|
||||
let is_exact_filename_match = query.eq_ignore_ascii_case(filename_without_ext);
|
||||
|
||||
// Adjust filename match indices to be relative to the full path
|
||||
let adjusted_filename_indices: Vec<usize> = filename_result
|
||||
.matched_indices
|
||||
.into_iter()
|
||||
.map(|idx| idx + filename_start_offset)
|
||||
.collect();
|
||||
|
||||
// Combine indices from both matches, removing duplicates
|
||||
let mut combined_indices = path_result.matched_indices.clone();
|
||||
for idx in adjusted_filename_indices {
|
||||
if !combined_indices.contains(&idx) {
|
||||
combined_indices.push(idx);
|
||||
}
|
||||
}
|
||||
combined_indices.sort_unstable();
|
||||
|
||||
// Calculate combined score with exact filename match boost
|
||||
let mut combined_score = if filename_has_matches {
|
||||
filename_score * 2 + path_result.score
|
||||
} else {
|
||||
path_result.score
|
||||
};
|
||||
|
||||
// Apply extra boost for exact filename matches (without extension)
|
||||
if is_exact_filename_match {
|
||||
combined_score += 5000; // Large boost to prioritize exact matches
|
||||
}
|
||||
|
||||
Some(FuzzyMatchResult {
|
||||
score: combined_score,
|
||||
matched_indices: combined_indices,
|
||||
})
|
||||
}
|
||||
(Some(mut filename_result), None) => {
|
||||
// Only filename matched, adjust indices to full path
|
||||
let filename_start_offset = path.len() - filename.len();
|
||||
filename_result.matched_indices = filename_result
|
||||
.matched_indices
|
||||
.into_iter()
|
||||
.map(|idx| idx + filename_start_offset)
|
||||
.collect();
|
||||
|
||||
// Check for exact filename match (without extension)
|
||||
let filename_without_ext = filename.split('.').next().unwrap_or(filename);
|
||||
let is_exact_filename_match = query.eq_ignore_ascii_case(filename_without_ext);
|
||||
|
||||
// Apply 2x weight to filename-only matches
|
||||
filename_result.score *= 2;
|
||||
|
||||
// Apply extra boost for exact filename matches (without extension)
|
||||
if is_exact_filename_match {
|
||||
filename_result.score += 5000; // Large boost to prioritize exact matches
|
||||
}
|
||||
|
||||
Some(filename_result)
|
||||
}
|
||||
(None, Some(path_result)) => {
|
||||
// Only path matched (excluding filename)
|
||||
Some(path_result)
|
||||
}
|
||||
(None, None) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// If `path` is absolute, strips the common prefix with `repo_root` (tried first)
|
||||
/// or `working_dir` (tried second) and returns the remaining relative path string.
|
||||
/// Returns `None` when the path is relative or neither prefix matches.
|
||||
pub fn strip_absolute_path_prefix(
|
||||
path: &str,
|
||||
repo_root: Option<&Path>,
|
||||
working_dir: Option<&Path>,
|
||||
) -> Option<String> {
|
||||
let query_path = Path::new(path);
|
||||
if !query_path.is_absolute() {
|
||||
return None;
|
||||
}
|
||||
if let Some(root) = repo_root {
|
||||
if let Ok(rel) = query_path.strip_prefix(root) {
|
||||
let s = rel.to_string_lossy().to_string();
|
||||
if !s.is_empty() {
|
||||
return Some(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(cwd) = working_dir {
|
||||
if let Ok(rel) = query_path.strip_prefix(cwd) {
|
||||
let s = rel.to_string_lossy().to_string();
|
||||
if !s.is_empty() {
|
||||
return Some(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Determines if a query is too broad and would match too many files
|
||||
/// causing performance issues. Returns true if we should skip the query.
|
||||
pub fn should_skip_overly_broad_query(query: &str) -> bool {
|
||||
let query = query.trim();
|
||||
|
||||
// Just a single wildcard
|
||||
if query == "*" || query == "?" {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Contains consecutive wildcards (very broad patterns)
|
||||
if query.contains("**")
|
||||
|| query.contains("*?")
|
||||
|| query.contains("?*")
|
||||
|| query.contains("??")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Apply path proximity ranking to the score
|
||||
/// Parts of the path closer to the filename get higher scores
|
||||
pub fn apply_path_proximity_ranking(
|
||||
path: &str,
|
||||
matched_indices: &[usize],
|
||||
original_score: i64,
|
||||
) -> i64 {
|
||||
if matched_indices.is_empty() {
|
||||
return original_score;
|
||||
}
|
||||
|
||||
let path_chars: Vec<char> = path.chars().collect();
|
||||
let total_chars = path_chars.len();
|
||||
|
||||
// Calculate weighted proximity score based on distance from end of path
|
||||
let proximity_bonus: i64 = matched_indices
|
||||
.iter()
|
||||
.map(|&idx| {
|
||||
// Distance from the end (closer to filename = smaller distance)
|
||||
let distance_from_end = total_chars.saturating_sub(idx + 1);
|
||||
// Convert to a bonus (closer = higher bonus)
|
||||
let max_distance = total_chars;
|
||||
if max_distance > 0 {
|
||||
((max_distance - distance_from_end) as f64 / max_distance as f64 * 10.0) as i64
|
||||
} else {
|
||||
0
|
||||
}
|
||||
})
|
||||
.sum();
|
||||
|
||||
original_score + proximity_bonus
|
||||
}
|
||||
|
||||
/// Get git changed files (modified, added, renamed, etc.) for prioritization
|
||||
#[cfg(feature = "local_fs")]
|
||||
pub fn get_git_changed_files(
|
||||
&self,
|
||||
repo_path: &Path,
|
||||
) -> Result<HashSet<String>, std::io::Error> {
|
||||
let mut changed_files = HashSet::new();
|
||||
|
||||
log::debug!("[GIT OPERATION] model.rs get_git_changed_files git status --porcelain");
|
||||
// Run `git status --porcelain` to get changed files
|
||||
let output = Command::new("git")
|
||||
.args(["status", "--porcelain"])
|
||||
.current_dir(repo_path)
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
// Not a git repository or git command failed
|
||||
return Ok(changed_files);
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
for line in stdout.lines() {
|
||||
if line.len() >= 3 {
|
||||
// Git status --porcelain format: XY filename
|
||||
// Where X is index status, Y is working tree status
|
||||
let filename = &line[3..];
|
||||
|
||||
// Handle quoted filenames (for special characters)
|
||||
let clean_filename = if filename.starts_with('"') && filename.ends_with('"') {
|
||||
// Basic unquoting - in practice git escapes special chars
|
||||
&filename[1..filename.len() - 1]
|
||||
} else {
|
||||
filename
|
||||
};
|
||||
|
||||
changed_files.insert(clean_filename.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(changed_files)
|
||||
}
|
||||
}
|
||||
|
||||
impl SingletonEntity for FileSearchModel {}
|
||||
|
||||
impl Entity for FileSearchModel {
|
||||
type Event = ();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "model_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,462 @@
|
||||
use super::super::search_item::{FileSearchItem, FileSearchResult};
|
||||
use super::FileSearchModel;
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
use repo_metadata::repositories::DetectedRepositories;
|
||||
use repo_metadata::RepoMetadataModel;
|
||||
use warpui::{App, SingletonEntity};
|
||||
|
||||
#[cfg(test)]
|
||||
mod file_search_model_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_file_search_model_creation() {
|
||||
App::test((), |app| async move {
|
||||
app.add_singleton_model(|_| DetectedRepositories::default());
|
||||
app.add_singleton_model(RepoMetadataModel::new);
|
||||
app.add_singleton_model(FileSearchModel::new);
|
||||
// Verify the singleton was registered and is accessible
|
||||
app.read(|app| {
|
||||
let _model = FileSearchModel::as_ref(app);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzzy_match_path_empty_query() {
|
||||
let result = FileSearchModel::fuzzy_match_path("src/main.rs", "");
|
||||
|
||||
assert!(result.is_some());
|
||||
let match_result = result.unwrap();
|
||||
assert_eq!(match_result.score, 0);
|
||||
assert!(match_result.matched_indices.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzzy_match_path_whitespace_only_query() {
|
||||
let result = FileSearchModel::fuzzy_match_path("src/main.rs", " ");
|
||||
|
||||
assert!(result.is_some());
|
||||
let match_result = result.unwrap();
|
||||
assert_eq!(match_result.score, 0);
|
||||
assert!(match_result.matched_indices.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzzy_match_path_simple_filename_match() {
|
||||
let result = FileSearchModel::fuzzy_match_path("src/main.rs", "main");
|
||||
|
||||
assert!(result.is_some());
|
||||
let match_result = result.unwrap();
|
||||
assert!(match_result.score > 0);
|
||||
assert!(!match_result.matched_indices.is_empty());
|
||||
|
||||
// Should match characters in "main" within "src/main.rs"
|
||||
let expected_indices = vec![4, 5, 6, 7]; // positions of 'm', 'a', 'i', 'n'
|
||||
assert_eq!(match_result.matched_indices, expected_indices);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzzy_match_path_exact_filename_match() {
|
||||
let result = FileSearchModel::fuzzy_match_path("src/main.rs", "main");
|
||||
|
||||
assert!(result.is_some());
|
||||
let match_result = result.unwrap();
|
||||
// Exact filename matches should get a large score boost
|
||||
assert!(match_result.score >= 5000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzzy_match_path_case_insensitive() {
|
||||
let result = FileSearchModel::fuzzy_match_path("src/Main.rs", "main");
|
||||
|
||||
assert!(result.is_some());
|
||||
let match_result = result.unwrap();
|
||||
assert!(match_result.score > 0);
|
||||
assert!(!match_result.matched_indices.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzzy_match_path_partial_match() {
|
||||
let result = FileSearchModel::fuzzy_match_path("src/components/button.tsx", "btn");
|
||||
|
||||
assert!(result.is_some());
|
||||
let match_result = result.unwrap();
|
||||
assert!(match_result.score > 0);
|
||||
// Should match 'b', 't' from "button"
|
||||
assert!(match_result.matched_indices.len() >= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzzy_match_path_no_match() {
|
||||
let result = FileSearchModel::fuzzy_match_path("src/main.rs", "xyz");
|
||||
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzzy_match_path_multi_term_search() {
|
||||
let result = FileSearchModel::fuzzy_match_path("src/components/button.tsx", "comp btn");
|
||||
|
||||
assert!(result.is_some());
|
||||
let match_result = result.unwrap();
|
||||
assert!(match_result.score > 0);
|
||||
// Should match characters from both "comp" and "btn"
|
||||
assert!(match_result.matched_indices.len() >= 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzzy_match_path_multi_term_partial_match() {
|
||||
let result = FileSearchModel::fuzzy_match_path("src/components/button.tsx", "comp xyz");
|
||||
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fuzzy_match_path_wildcard_patterns() {
|
||||
// Test * wildcard for file extension
|
||||
let result = FileSearchModel::fuzzy_match_path("src/main.rs", "*.rs");
|
||||
assert!(result.is_some());
|
||||
|
||||
// Test * wildcard for filename
|
||||
let result = FileSearchModel::fuzzy_match_path("src/main.rs", "main*");
|
||||
assert!(result.is_some());
|
||||
|
||||
// Test ? wildcard for single character
|
||||
let result = FileSearchModel::fuzzy_match_path("src/main.rs", "main.?s");
|
||||
assert!(result.is_some());
|
||||
|
||||
// Test patterns that should not match
|
||||
let result = FileSearchModel::fuzzy_match_path("src/main.rs", "*.tsx");
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_skip_overly_broad_query() {
|
||||
// Single wildcards should be skipped
|
||||
assert!(FileSearchModel::should_skip_overly_broad_query("*"));
|
||||
assert!(FileSearchModel::should_skip_overly_broad_query("?"));
|
||||
assert!(FileSearchModel::should_skip_overly_broad_query(" * "));
|
||||
|
||||
// Consecutive wildcards should be skipped
|
||||
assert!(FileSearchModel::should_skip_overly_broad_query("**"));
|
||||
assert!(FileSearchModel::should_skip_overly_broad_query("*?"));
|
||||
assert!(FileSearchModel::should_skip_overly_broad_query("?*"));
|
||||
assert!(FileSearchModel::should_skip_overly_broad_query("??"));
|
||||
|
||||
// Valid queries should not be skipped
|
||||
assert!(!FileSearchModel::should_skip_overly_broad_query("main"));
|
||||
assert!(!FileSearchModel::should_skip_overly_broad_query("*.rs"));
|
||||
assert!(!FileSearchModel::should_skip_overly_broad_query("src/main"));
|
||||
assert!(!FileSearchModel::should_skip_overly_broad_query("a*b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filename_prioritization() {
|
||||
// Filename matches should score higher than path matches
|
||||
let filename_match =
|
||||
FileSearchModel::fuzzy_match_path("long/path/to/test.rs", "test").unwrap();
|
||||
let path_match =
|
||||
FileSearchModel::fuzzy_match_path("test/path/to/other.rs", "test").unwrap();
|
||||
|
||||
// Filename match should have higher score due to 2x multiplier
|
||||
assert!(filename_match.score > path_match.score);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_path_with_extension_exact_match() {
|
||||
let result = FileSearchModel::fuzzy_match_path("src/test.rs", "test");
|
||||
|
||||
assert!(result.is_some());
|
||||
let match_result = result.unwrap();
|
||||
// Should get exact match bonus even with extension
|
||||
assert!(match_result.score >= 5000);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod file_search_item_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_file_search_item_from_result() {
|
||||
let result = FileSearchResult {
|
||||
path: "src/main.rs".to_string(),
|
||||
project_directory: "/Users/test_user/project".to_string(),
|
||||
is_directory: false,
|
||||
};
|
||||
|
||||
let match_result = FuzzyMatchResult {
|
||||
score: 100,
|
||||
matched_indices: vec![0, 1, 2],
|
||||
};
|
||||
|
||||
let item = FileSearchItem::from_result(result, match_result);
|
||||
|
||||
assert_eq!(item.path, "src/main.rs");
|
||||
assert!(!item.is_directory);
|
||||
assert_eq!(item.match_result.score, 100);
|
||||
assert_eq!(item.match_result.matched_indices, vec![0, 1, 2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_search_item_from_result_no_match() {
|
||||
let result = FileSearchResult {
|
||||
path: "src/test.rs".to_string(),
|
||||
project_directory: "/Users/test_user/project".to_string(),
|
||||
is_directory: false,
|
||||
};
|
||||
|
||||
let item = FileSearchItem::from_result_no_match(result);
|
||||
|
||||
assert_eq!(item.path, "src/test.rs");
|
||||
assert!(!item.is_directory);
|
||||
assert_eq!(item.match_result.score, 0);
|
||||
assert!(item.match_result.matched_indices.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_search_item_directory() {
|
||||
let result = FileSearchResult {
|
||||
path: "src/components".to_string(),
|
||||
project_directory: "/Users/test_user/project".to_string(),
|
||||
is_directory: true,
|
||||
};
|
||||
|
||||
let item = FileSearchItem::from_result_no_match(result);
|
||||
|
||||
assert_eq!(item.path, "src/components");
|
||||
assert!(item.is_directory);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_file_search_item_clone() {
|
||||
let result = FileSearchResult {
|
||||
path: "test.txt".to_string(),
|
||||
project_directory: "/Users/test_user/project".to_string(),
|
||||
is_directory: false,
|
||||
};
|
||||
|
||||
let original = FileSearchItem::from_result_no_match(result);
|
||||
let cloned = original.clone();
|
||||
|
||||
assert_eq!(original.path, cloned.path);
|
||||
assert_eq!(original.is_directory, cloned.is_directory);
|
||||
assert_eq!(original.match_result.score, cloned.match_result.score);
|
||||
assert_eq!(
|
||||
original.match_result.matched_indices,
|
||||
cloned.match_result.matched_indices
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod strip_absolute_path_prefix_tests {
|
||||
use super::*;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Builds an absolute path from the given components, using the platform's
|
||||
/// root (`/` on Unix, `C:\` on Windows). This ensures the constructed
|
||||
/// path is treated as absolute by `Path::is_absolute` on both platforms.
|
||||
fn abs_path(components: &[&str]) -> String {
|
||||
let mut path = PathBuf::new();
|
||||
#[cfg(windows)]
|
||||
path.push(r"C:\");
|
||||
#[cfg(unix)]
|
||||
path.push("/");
|
||||
for component in components {
|
||||
path.push(component);
|
||||
}
|
||||
path.to_string_lossy().into_owned()
|
||||
}
|
||||
|
||||
/// Builds a relative path using the platform's native separator, so tests
|
||||
/// can compare against `strip_prefix` output without hardcoding `/` or `\`.
|
||||
fn rel_path(components: &[&str]) -> String {
|
||||
components
|
||||
.iter()
|
||||
.collect::<PathBuf>()
|
||||
.to_string_lossy()
|
||||
.into_owned()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_relative_path_unchanged() {
|
||||
let repo_root = abs_path(&["home", "user", "project"]);
|
||||
let result = FileSearchModel::strip_absolute_path_prefix(
|
||||
"src/main.rs",
|
||||
Some(Path::new(&repo_root)),
|
||||
None,
|
||||
);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strips_repo_root() {
|
||||
let abs = abs_path(&["home", "user", "project", "src", "main.rs"]);
|
||||
let repo_root = abs_path(&["home", "user", "project"]);
|
||||
let expected = rel_path(&["src", "main.rs"]);
|
||||
let result =
|
||||
FileSearchModel::strip_absolute_path_prefix(&abs, Some(Path::new(&repo_root)), None);
|
||||
assert_eq!(result.as_deref(), Some(expected.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strips_working_dir_when_no_repo_root() {
|
||||
let abs = abs_path(&["home", "user", "project", "src", "main.rs"]);
|
||||
let working_dir = abs_path(&["home", "user", "project"]);
|
||||
let expected = rel_path(&["src", "main.rs"]);
|
||||
let result =
|
||||
FileSearchModel::strip_absolute_path_prefix(&abs, None, Some(Path::new(&working_dir)));
|
||||
assert_eq!(result.as_deref(), Some(expected.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_prefers_repo_root_over_working_dir() {
|
||||
let abs = abs_path(&["repo", "src", "main.rs"]);
|
||||
let repo_root = abs_path(&["repo"]);
|
||||
let working_dir = abs_path(&["other"]);
|
||||
let expected = rel_path(&["src", "main.rs"]);
|
||||
let result = FileSearchModel::strip_absolute_path_prefix(
|
||||
&abs,
|
||||
Some(Path::new(&repo_root)),
|
||||
Some(Path::new(&working_dir)),
|
||||
);
|
||||
assert_eq!(result.as_deref(), Some(expected.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_falls_back_to_working_dir_when_repo_root_does_not_match() {
|
||||
let abs = abs_path(&["home", "user", "project", "src", "main.rs"]);
|
||||
let repo_root = abs_path(&["other", "repo"]);
|
||||
let working_dir = abs_path(&["home", "user", "project"]);
|
||||
let expected = rel_path(&["src", "main.rs"]);
|
||||
let result = FileSearchModel::strip_absolute_path_prefix(
|
||||
&abs,
|
||||
Some(Path::new(&repo_root)),
|
||||
Some(Path::new(&working_dir)),
|
||||
);
|
||||
assert_eq!(result.as_deref(), Some(expected.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_returns_none_when_no_prefix_matches() {
|
||||
let abs = abs_path(&["home", "user", "project", "src", "main.rs"]);
|
||||
let repo_root = abs_path(&["other", "repo"]);
|
||||
let working_dir = abs_path(&["another", "dir"]);
|
||||
let result = FileSearchModel::strip_absolute_path_prefix(
|
||||
&abs,
|
||||
Some(Path::new(&repo_root)),
|
||||
Some(Path::new(&working_dir)),
|
||||
);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_returns_none_when_path_equals_prefix() {
|
||||
// Exact match with no remaining path should return None.
|
||||
let abs = abs_path(&["home", "user", "project"]);
|
||||
let result = FileSearchModel::strip_absolute_path_prefix(&abs, Some(Path::new(&abs)), None);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_roots_provided() {
|
||||
let abs = abs_path(&["home", "user", "project", "src", "main.rs"]);
|
||||
let result = FileSearchModel::strip_absolute_path_prefix(&abs, None, None);
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_working_dir_inside_repo_root_strips_repo_root() {
|
||||
// When the working directory is a subdirectory of the repo root, the
|
||||
// returned path is relative to the repo root (not the working dir),
|
||||
// since repo_root is tried first. This keeps query normalization
|
||||
// aligned with how the file index stores paths.
|
||||
let abs = abs_path(&["home", "user", "project", "src", "main.rs"]);
|
||||
let repo_root = abs_path(&["home", "user", "project"]);
|
||||
let working_dir = abs_path(&["home", "user", "project", "src"]);
|
||||
let expected = rel_path(&["src", "main.rs"]);
|
||||
let result = FileSearchModel::strip_absolute_path_prefix(
|
||||
&abs,
|
||||
Some(Path::new(&repo_root)),
|
||||
Some(Path::new(&working_dir)),
|
||||
);
|
||||
assert_eq!(result.as_deref(), Some(expected.as_str()));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod integration_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_typical_search_workflow() {
|
||||
// Simulate a typical search workflow
|
||||
let paths = vec![
|
||||
"src/main.rs",
|
||||
"src/lib.rs",
|
||||
"src/components/button.tsx",
|
||||
"src/components/input.tsx",
|
||||
"tests/integration_test.rs",
|
||||
"README.md",
|
||||
];
|
||||
|
||||
let query = "main";
|
||||
|
||||
let mut results = Vec::new();
|
||||
for path in paths {
|
||||
if let Some(match_result) = FileSearchModel::fuzzy_match_path(path, query) {
|
||||
let search_result = FileSearchResult {
|
||||
path: path.to_string(),
|
||||
project_directory: "/Users/test_user/project".to_string(),
|
||||
is_directory: false,
|
||||
};
|
||||
let search_item = FileSearchItem::from_result(search_result, match_result);
|
||||
results.push(search_item);
|
||||
}
|
||||
}
|
||||
|
||||
// Should find "src/main.rs"
|
||||
assert!(!results.is_empty());
|
||||
|
||||
// Sort by score (highest first)
|
||||
results.sort_by(|a, b| b.match_result.score.cmp(&a.match_result.score));
|
||||
|
||||
// "src/main.rs" should be the top result (exact filename match)
|
||||
assert_eq!(results[0].path, "src/main.rs");
|
||||
assert!(results[0].match_result.score >= 5000); // Should have exact match bonus
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multi_term_search_workflow() {
|
||||
let paths = vec![
|
||||
"src/components/button.tsx",
|
||||
"src/components/input.tsx",
|
||||
"src/utils/button_helper.rs",
|
||||
"tests/button_test.rs",
|
||||
];
|
||||
|
||||
let query = "comp button";
|
||||
|
||||
let mut results = Vec::new();
|
||||
for path in paths {
|
||||
if let Some(match_result) = FileSearchModel::fuzzy_match_path(path, query) {
|
||||
let search_result = FileSearchResult {
|
||||
path: path.to_string(),
|
||||
project_directory: "/Users/test_user/project".to_string(),
|
||||
is_directory: false,
|
||||
};
|
||||
results.push(FileSearchItem::from_result(search_result, match_result));
|
||||
}
|
||||
}
|
||||
|
||||
// Should find "src/components/button.tsx" as it matches both terms
|
||||
assert!(!results.is_empty());
|
||||
|
||||
let button_component = results
|
||||
.iter()
|
||||
.find(|item| item.path == "src/components/button.tsx");
|
||||
assert!(button_component.is_some());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
use fuzzy_match::FuzzyMatchResult;
|
||||
|
||||
/// Basic file search result structure that can be used across different UI components.
|
||||
/// This is the common data format returned by the FileSearchModel.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileSearchResult {
|
||||
pub path: String,
|
||||
pub project_directory: String,
|
||||
pub is_directory: bool,
|
||||
}
|
||||
|
||||
/// Extended file search item that includes match results for UI rendering.
|
||||
/// UI components can convert FileSearchResult + FuzzyMatchResult into this.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileSearchItem {
|
||||
pub path: String,
|
||||
pub match_result: FuzzyMatchResult,
|
||||
pub is_directory: bool,
|
||||
}
|
||||
|
||||
impl FileSearchItem {
|
||||
/// Create a FileSearchItem from a FileSearchResult and match result
|
||||
pub fn from_result(result: FileSearchResult, match_result: FuzzyMatchResult) -> Self {
|
||||
Self {
|
||||
path: result.path,
|
||||
match_result,
|
||||
is_directory: result.is_directory,
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a FileSearchItem with no match highlighting (for zero state)
|
||||
pub fn from_result_no_match(result: FileSearchResult) -> Self {
|
||||
Self {
|
||||
path: result.path,
|
||||
match_result: FuzzyMatchResult::no_match(),
|
||||
is_directory: result.is_directory,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user