first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+4 -2
View File
@@ -1,6 +1,8 @@
use crate::search::result_renderer::ItemHighlightState;
use galaxy_core::ui::appearance::Appearance;
use galaxyui::{elements::Icon, Element};
use galaxyui::elements::Icon;
use galaxyui::Element;
use crate::search::result_renderer::ItemHighlightState;
/// Assumes the path is a file, not a folder
pub fn icon_from_file_path(
+260 -125
View File
@@ -1,14 +1,13 @@
#![cfg_attr(not(feature = "local_fs"), allow(dead_code))]
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use fuzzy_match::{
contains_wildcards, match_indices_case_insensitive, match_wildcard_pattern_case_insensitive,
FuzzyMatchResult,
};
use galaxyui::{AppContext, Entity, ModelContext, SingletonEntity};
use std::sync::Arc;
use std::{
collections::HashSet,
path::{Path, PathBuf},
};
cfg_if::cfg_if! {
if #[cfg(feature = "local_fs")] {
@@ -17,9 +16,11 @@ cfg_if::cfg_if! {
use repo_metadata::local_model::GetContentsArgs;
use repo_metadata::wrapper_model::RepoMetadataEvent;
use repo_metadata::RepoMetadataModel;
use repo_metadata::repository_identifier::RepositoryIdentifier;
use repo_metadata::repositories::DetectedRepositories;
use std::cell::RefCell;
use std::collections::HashMap;
use warp_util::local_or_remote_path::LocalOrRemotePath;
}
}
@@ -28,10 +29,10 @@ 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.
/// Cached flattened repo contents keyed by repo root location (local or remote).
/// Populated lazily on first query, invalidated when the file tree changes.
#[cfg(feature = "local_fs")]
repo_contents_cache: RefCell<HashMap<PathBuf, Arc<Vec<FileSearchResult>>>>,
repo_contents_cache: RefCell<HashMap<LocalOrRemotePath, Arc<Vec<FileSearchResult>>>>,
}
impl FileSearchModel {
@@ -40,32 +41,23 @@ impl FileSearchModel {
#[cfg(feature = "local_fs")]
ctx.subscribe_to_model(
&RepoMetadataModel::handle(ctx),
|me, event, _ctx| match event {
|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);
}
if let Some(key) = id.to_local_or_remote_path() {
cache.remove(&key);
}
}
}
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::RepositoryRemoved { id }
| RepoMetadataEvent::RepositoryUpdated { id } => {
if let Some(key) = id.to_local_or_remote_path() {
me.repo_contents_cache.borrow_mut().remove(&key);
}
}
RepoMetadataEvent::FileTreeEntryUpdated { .. }
| RepoMetadataEvent::StandingQueryResultsUpdated { .. }
| RepoMetadataEvent::UpdatingRepositoryFailed { .. }
| RepoMetadataEvent::IncrementalUpdateReady { .. } => {}
},
@@ -84,12 +76,26 @@ impl FileSearchModel {
#[cfg(feature = "local_fs")]
pub fn repo_root(&self, app: &AppContext) -> Option<PathBuf> {
self.repo_root_location(app)
.and_then(|loc| PathBuf::try_from(loc).ok())
}
/// Returns the repo root as a `LocalOrRemotePath`, supporting both local and SSH sessions.
#[cfg(not(feature = "local_fs"))]
pub fn repo_root_location(
&self,
_app: &AppContext,
) -> Option<warp_util::local_or_remote_path::LocalOrRemotePath> {
None
}
/// Returns the repo root as a `LocalOrRemotePath`, supporting both local and SSH sessions.
#[cfg(feature = "local_fs")]
pub fn repo_root_location(&self, app: &AppContext) -> Option<LocalOrRemotePath> {
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)
let working_dir =
active_window_id.and_then(|wid| ActiveSession::as_ref(app).working_directory(wid))?;
DetectedRepositories::as_ref(app).get_root_for_path(working_dir)
}
#[cfg(not(feature = "local_fs"))]
@@ -97,66 +103,116 @@ impl FileSearchModel {
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.
/// Fetches files and folders from the current working directory (non-recursive).
///
/// For local sessions this reads the filesystem directly via `std::fs::read_dir`.
/// For remote sessions it queries the `RepoMetadataModel`, which receives
/// lazy-loaded first-level snapshots from the remote server on every
/// `NavigatedToDirectory`.
#[cfg(feature = "local_fs")]
pub fn get_folder_contents(&self, app: &AppContext) -> Vec<FileSearchResult> {
let active_window_id = app.windows().state().active_window;
let working_dir =
active_window_id.and_then(|wid| ActiveSession::as_ref(app).working_directory(wid));
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(),
};
match working_dir {
// Local session: read the filesystem directly.
Some(LocalOrRemotePath::Local(ref local_path)) => {
let current_dir: &Path = local_path.as_path();
let current_dir_string = current_dir.to_string_lossy().to_string();
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()
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()
}
}
}
// Remote session: query repo metadata (populated by the remote
// server's NavigatedToDirectory lazy-load).
Some(LocalOrRemotePath::Remote(ref remote_path)) => {
let id = RepositoryIdentifier::Remote(remote_path.clone());
let repo_metadata = RepoMetadataModel::as_ref(app);
// Truncated results (capped at the repo metadata budget) are
// intentionally used as-is to return partial matches rather
// than nothing.
let contents =
match repo_metadata.get_repo_contents(&id, GetContentsArgs::default(), app) {
Ok(repo_contents) => repo_contents.contents,
Err(_) => return Vec::new(),
};
let root_std_path = &remote_path.path;
contents
.iter()
.filter_map(|content| {
let (path_std, is_directory) = match content {
repo_metadata::RepoContent::File(file) => (&*file.path, false),
repo_metadata::RepoContent::Directory(dir) => (&*dir.path, true),
};
let relative = path_std.strip_prefix(root_std_path)?;
// Only include direct children (no nested paths).
let trimmed = relative.trim_end_matches('/');
if trimmed.is_empty() || trimmed.contains('/') {
return None;
}
let mut path = relative.to_owned();
if is_directory && !path.ends_with('/') {
path.push('/');
}
Some(FileSearchResult {
path,
project_directory: root_std_path.to_string(),
is_directory,
})
})
.collect()
}
None => 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.
/// Gets repository contents (files and directories) for the current working directory.
/// Supports both local and remote repos.
///
/// When `query` is non-empty, it is pushed down into the repo-metadata
/// traversal as a filter so the result cap applies to *matching* files
/// rather than the first files encountered in traversal order. These
/// query-specific results are not cached. When `query` is empty (zero
/// state) the full unfiltered contents are returned and cached per repo
/// root location, 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 {
pub fn get_repo_contents(&self, query: &str, app: &AppContext) -> Arc<Vec<FileSearchResult>> {
let Some(repo_root) = self.repo_root_location(app) else {
return Arc::new(Vec::new());
};
// Query-filtered results are query-specific, so bypass the per-repo
// cache and traverse the in-memory index fresh.
if !query.is_empty() {
return Arc::new(self.get_contents_from_repo(&repo_root, query, app));
}
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 contents = self.get_contents_from_repo(&repo_root, query, app);
let arc = Arc::new(contents);
self.repo_contents_cache
@@ -172,7 +228,7 @@ impl FileSearchModel {
&self,
app: &AppContext,
) -> (Arc<Vec<FileSearchResult>>, HashSet<String>) {
let contents = self.get_repo_contents(app);
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())
@@ -182,7 +238,7 @@ impl FileSearchModel {
/// 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>> {
pub fn get_repo_contents(&self, _query: &str, _app: &AppContext) -> Arc<Vec<FileSearchResult>> {
Arc::new(Vec::new())
}
@@ -195,71 +251,150 @@ impl FileSearchModel {
(Arc::new(Vec::new()), HashSet::new())
}
/// Helper method to get repository contents from a specific repository
/// Builds the [`GetContentsArgs`] used to traverse repo metadata.
///
/// For an empty `query` this returns the default args (unfiltered). For a
/// non-empty `query` it installs a traversal filter that keeps only entries
/// whose repo-relative path (produced by `relative_path`) fuzzy-matches the
/// query. Pushing the query into traversal ensures the result cap applies
/// to *matching* files rather than the first files encountered.
#[cfg(feature = "local_fs")]
fn contents_args<F>(query: &str, relative_path: F) -> GetContentsArgs
where
F: for<'a> Fn(&repo_metadata::RepoContent<'a>) -> Option<String> + Send + Sync + 'static,
{
if query.is_empty() {
return GetContentsArgs::default();
}
let query = query.to_string();
GetContentsArgs::default().with_filter(move |content| {
relative_path(content)
.is_some_and(|path| FileSearchModel::fuzzy_match_path(&path, &query).is_some())
})
}
/// Gets repository contents for a local or remote repo root, converting
/// absolute paths to repo-relative `FileSearchResult`s.
///
/// When `query` is non-empty it is pushed down as a traversal filter (see
/// [`Self::contents_args`]) so the repo-metadata result cap applies to
/// matching files rather than the first files encountered in traversal
/// order.
#[cfg(feature = "local_fs")]
fn get_contents_from_repo(
&self,
repo_path: &Path,
repo_metadata: &repo_metadata::wrapper_model::RepoMetadataModel,
args: GetContentsArgs,
repo_root: &LocalOrRemotePath,
query: &str,
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 repo_metadata = RepoMetadataModel::as_ref(app);
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 {
match repo_root {
LocalOrRemotePath::Local(local_path) => {
let Ok(canonical_repo_path) = dunce::canonicalize(local_path) else {
return Vec::new();
};
let Some(id) = RepositoryIdentifier::try_local(local_path) else {
return Vec::new();
};
let args = Self::contents_args(query, {
let canonical_repo_path = canonical_repo_path.clone();
move |content| {
let local = match content {
repo_metadata::RepoContent::File(file) => {
file.path.to_local_path_lossy()
}
repo_metadata::RepoContent::Directory(dir) => {
dir.path.to_local_path_lossy()
}
};
local
.strip_prefix(&canonical_repo_path)
.ok()
.map(|relative| relative.to_string_lossy().to_string())
}
});
// Truncated results (capped at the repo metadata budget) are
// intentionally used as-is to return partial matches rather
// than nothing.
let contents = match repo_metadata.get_repo_contents(&id, args, app) {
Ok(repo_contents) => repo_contents.contents,
Err(_) => return Vec::new(),
};
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
}
let relative_path =
file_local.strip_prefix(&canonical_repo_path).ok()?;
Some(FileSearchResult {
path: relative_path.to_string_lossy().to_string(),
project_directory: canonical_repo_path
.to_string_lossy()
.to_string(),
is_directory: false,
})
}
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
let relative_path =
dir_local.strip_prefix(&canonical_repo_path).ok()?;
let mut path = relative_path.to_string_lossy().to_string();
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,
})
}
})
.collect()
}
LocalOrRemotePath::Remote(remote_path) => {
let id = RepositoryIdentifier::Remote(remote_path.clone());
let args = Self::contents_args(query, {
let root = remote_path.path.clone();
move |content| {
let path_std = match content {
repo_metadata::RepoContent::File(file) => &*file.path,
repo_metadata::RepoContent::Directory(dir) => &*dir.path,
};
path_std.strip_prefix(&root).map(str::to_owned)
}
})
.collect()
} else {
Vec::new()
});
// Truncated results (capped at the repo metadata budget) are
// intentionally used as-is to return partial matches rather
// than nothing.
let contents = match repo_metadata.get_repo_contents(&id, args, app) {
Ok(repo_contents) => repo_contents.contents,
Err(_) => return Vec::new(),
};
let root_std_path = &remote_path.path;
contents
.iter()
.filter_map(|content| {
let (path_std, is_directory) = match content {
repo_metadata::RepoContent::File(file) => (&*file.path, false),
repo_metadata::RepoContent::Directory(dir) => (&*dir.path, true),
};
let relative = path_std.strip_prefix(root_std_path)?;
let mut path = relative.to_owned();
if is_directory && !path.ends_with('/') {
path.push('/');
}
Some(FileSearchResult {
path,
project_directory: root_std_path.to_string(),
is_directory,
})
})
.collect()
}
}
}
+6 -7
View File
@@ -1,10 +1,11 @@
use super::super::search_item::{FileSearchItem, FileSearchResult};
use super::FileSearchModel;
use fuzzy_match::FuzzyMatchResult;
use galaxyui::{App, SingletonEntity};
use repo_metadata::repositories::DetectedRepositories;
use repo_metadata::RepoMetadataModel;
use super::super::search_item::{FileSearchItem, FileSearchResult};
use super::FileSearchModel;
#[cfg(test)]
mod file_search_model_tests {
use super::*;
@@ -176,7 +177,6 @@ mod file_search_model_tests {
#[cfg(test)]
mod file_search_item_tests {
use super::*;
#[test]
fn test_file_search_item_from_result() {
@@ -252,9 +252,9 @@ mod file_search_item_tests {
#[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.
@@ -388,7 +388,6 @@ mod strip_absolute_path_prefix_tests {
#[cfg(test)]
mod integration_tests {
use super::*;
#[test]
fn test_typical_search_workflow() {
@@ -398,7 +397,7 @@ mod integration_tests {
"src/lib.rs",
"src/components/button.tsx",
"src/components/input.tsx",
"tests/integration_test.rs",
"tests/integration_tests.rs",
"README.md",
];
@@ -434,7 +433,7 @@ mod integration_tests {
"src/components/button.tsx",
"src/components/input.tsx",
"src/utils/button_helper.rs",
"tests/button_test.rs",
"tests/button_tests.rs",
];
let query = "comp button";