Initial public release of Warp.

Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
David Stern
2026-04-28 08:43:33 -05:00
commit 0dbd3d567a
4982 changed files with 1431549 additions and 0 deletions
+20
View File
@@ -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(),
}
}
+3
View File
@@ -0,0 +1,3 @@
pub mod icon;
pub mod model;
pub mod search_item;
+550
View File
@@ -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;
+462
View File
@@ -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());
}
}
+39
View File
@@ -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,
}
}
}