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
+8
View File
@@ -0,0 +1,8 @@
//! File picker component for rendering expandable folder structures.
pub mod snapshot;
#[cfg_attr(not(feature = "local_fs"), allow(dead_code, unused_imports))]
mod view;
pub use view::*;
+368
View File
@@ -0,0 +1,368 @@
#![allow(dead_code)]
//! SumTree-based file tree snapshot for efficient lookups and virtualized rendering.
//!
//! This module provides a SumTree-based data model for the file tree view.
#[path = "snapshot/iterator.rs"]
mod iterator;
use std::{cmp::Ordering, ops::AddAssign, path::Path, sync::Arc};
use sum_tree::{Edit, KeyedItem, SeekBias, SumTree};
/// Represents a single entry in the file tree.
#[derive(Clone, Debug)]
pub struct FileEntry {
/// The absolute path to this entry.
pub path: Arc<Path>,
/// Whether this is a file or directory.
pub kind: FileEntryKind,
/// Whether this entry is ignored by gitignore.
pub ignored: bool,
/// For directories: whether the contents have been loaded.
/// For files: always true.
pub loaded: bool,
}
impl FileEntry {
/// Creates a new file entry.
pub fn file(path: impl Into<Arc<Path>>, ignored: bool) -> Self {
let path = path.into();
let extension = path.extension().and_then(|e| e.to_str()).map(Arc::from);
Self {
path,
kind: FileEntryKind::File { extension },
ignored,
loaded: true,
}
}
/// Creates a new directory entry.
pub fn directory(path: impl Into<Arc<Path>>, ignored: bool, loaded: bool) -> Self {
Self {
path: path.into(),
kind: FileEntryKind::Directory,
ignored,
loaded,
}
}
/// Returns true if this is a directory.
pub fn is_dir(&self) -> bool {
matches!(self.kind, FileEntryKind::Directory)
}
/// Returns true if this is a file.
pub fn is_file(&self) -> bool {
matches!(self.kind, FileEntryKind::File { .. })
}
/// Returns the file extension if this is a file.
#[cfg(test)]
pub fn extension(&self) -> Option<&str> {
match &self.kind {
FileEntryKind::File { extension } => extension.as_deref(),
FileEntryKind::Directory => None,
}
}
}
/// The kind of file tree entry.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum FileEntryKind {
File { extension: Option<Arc<str>> },
Directory,
}
/// Summary of file entries for aggregate queries.
#[derive(Clone, Debug)]
pub struct FileEntrySummary {
/// The maximum (lexicographically last) path in this subtree.
max_path: Arc<Path>,
/// Total count of entries in this subtree.
count: usize,
/// Count of non-ignored entries in this subtree.
visible_count: usize,
/// Count of files (not directories) in this subtree.
file_count: usize,
/// Count of non-ignored files in this subtree.
visible_file_count: usize,
}
impl Default for FileEntrySummary {
fn default() -> Self {
Self {
max_path: Arc::from(Path::new("")),
count: 0,
visible_count: 0,
file_count: 0,
visible_file_count: 0,
}
}
}
impl AddAssign<&FileEntrySummary> for FileEntrySummary {
fn add_assign(&mut self, rhs: &FileEntrySummary) {
// Entries are sorted by path, so the rightmost (rhs) summary has the max path.
self.max_path = rhs.max_path.clone();
self.count += rhs.count;
self.visible_count += rhs.visible_count;
self.file_count += rhs.file_count;
self.visible_file_count += rhs.visible_file_count;
}
}
impl sum_tree::Item for FileEntry {
type Summary = FileEntrySummary;
fn summary(&self) -> Self::Summary {
let is_visible = !self.ignored;
let is_file = self.is_file();
FileEntrySummary {
max_path: self.path.clone(),
count: 1,
visible_count: if is_visible { 1 } else { 0 },
file_count: if is_file { 1 } else { 0 },
visible_file_count: if is_visible && is_file { 1 } else { 0 },
}
}
}
/// Newtype for path-based lookups in the SumTree.
///
/// We can't use `Arc<Path>` directly because:
/// 1. Orphan rule: can't impl `sum_tree::Dimension` for external type
/// 2. `Arc<Path>` has no `Default` impl, which SumTree cursors require
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PathKey(pub Arc<Path>);
impl Default for PathKey {
fn default() -> Self {
Self(Arc::from(Path::new("")))
}
}
impl PathKey {
pub fn new(path: impl Into<Arc<Path>>) -> Self {
Self(path.into())
}
}
impl Ord for PathKey {
fn cmp(&self, other: &Self) -> Ordering {
self.0.cmp(&other.0)
}
}
impl PartialOrd for PathKey {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl<'a> sum_tree::Dimension<'a, FileEntrySummary> for PathKey {
fn add_summary(&mut self, summary: &'a FileEntrySummary) {
self.0 = summary.max_path.clone();
}
}
impl KeyedItem for FileEntry {
type Key = PathKey;
fn key(&self) -> Self::Key {
PathKey(self.path.clone())
}
}
/// A snapshot of the file tree stored in a SumTree.
#[derive(Clone, Debug)]
pub struct FileTreeSnapshot {
/// The root path of this file tree.
root_path: Arc<Path>,
/// Entries sorted by path.
pub(super) entries_by_path: SumTree<FileEntry>,
}
impl FileTreeSnapshot {
/// Creates an empty snapshot with the given root path.
pub fn new(root_path: impl Into<Arc<Path>>) -> Self {
Self {
root_path: root_path.into(),
entries_by_path: SumTree::new(),
}
}
/// Creates a snapshot with a root directory entry.
pub fn with_root(root_path: impl Into<Arc<Path>>, ignored: bool, loaded: bool) -> Self {
let root_path = root_path.into();
let mut snapshot = Self::new(root_path.clone());
snapshot.insert_entry(FileEntry::directory(root_path, ignored, loaded));
snapshot
}
/// Returns the root path of this file tree.
pub fn root_path(&self) -> &Arc<Path> {
&self.root_path
}
/// Returns the total number of entries.
#[cfg(test)]
pub fn len(&self) -> usize {
self.entries_by_path.summary().count
}
/// Returns true if there are no entries.
#[cfg(test)]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// Looks up an entry by path. O(log n).
pub fn entry_for_path(&self, path: &Path) -> Option<&FileEntry> {
let key = PathKey::new(Arc::from(path));
let mut cursor = self.entries_by_path.cursor::<PathKey, ()>();
cursor.seek(&key, SeekBias::Left);
cursor.item().filter(|entry| entry.path.as_ref() == path)
}
/// Inserts or updates an entry. O(log n).
pub fn insert_entry(&mut self, entry: FileEntry) {
self.entries_by_path.edit(&mut [Edit::Insert(entry)]);
}
/// Removes an entry by path. O(log n).
pub fn remove_entry(&mut self, path: &Path) {
if let Some(entry) = self.entry_for_path(path).cloned() {
self.entries_by_path.edit(&mut [Edit::Remove(entry)]);
}
}
/// Returns an iterator over direct children of the given directory path.
pub fn child_entries<'a>(
&'a self,
parent_path: &'a Path,
) -> impl Iterator<Item = &'a FileEntry> {
iterator::ChildEntriesIter::new(self, parent_path)
}
/// Checks if the parent directory of the given path is loaded.
/// Returns true if the parent exists and is loaded, or if the path is the root.
pub fn is_parent_loaded(&self, path: &Path) -> bool {
let Some(parent) = path.parent() else {
// No parent means this is a root-level path
return true;
};
// If parent is the root path, check if it's loaded
if parent == self.root_path.as_ref() {
return self
.entry_for_path(parent)
.is_some_and(|e| e.is_dir() && e.loaded);
}
// Check if parent directory exists and is loaded
self.entry_for_path(parent)
.is_some_and(|e| e.is_dir() && e.loaded)
}
/// Handles a file/directory being added.
/// Returns true if the entry was added, false if the parent is not loaded.
pub fn handle_added(&mut self, path: &Path, is_dir: bool, ignored: bool) -> bool {
if !self.is_parent_loaded(path) {
return false;
}
let entry = if is_dir {
FileEntry::directory(Arc::from(path), ignored, false)
} else {
FileEntry::file(Arc::from(path), ignored)
};
self.insert_entry(entry);
true
}
/// Handles a file/directory being removed.
/// Returns true if the entry was removed, false if it didn't exist or parent is not loaded.
pub fn handle_removed(&mut self, path: &Path) -> bool {
if !self.is_parent_loaded(path) {
return false;
}
if self.entry_for_path(path).is_some() {
self.remove_entry(path);
true
} else {
false
}
}
/// Renames an entry from old_path to new_path, preserving its properties.
pub fn rename_entry(&mut self, old_path: &Path, new_path: &Path) {
let Some(old_entry) = self.entry_for_path(old_path).cloned() else {
return;
};
// Remove the old entry
self.remove_entry(old_path);
// Create a new entry at the new path with the same properties
let new_entry = FileEntry {
path: Arc::from(new_path),
kind: old_entry.kind,
ignored: old_entry.ignored,
loaded: old_entry.loaded,
};
self.insert_entry(new_entry);
}
/// Expands a directory by marking it as loaded.
pub fn expand_directory(&mut self, path: &Path) -> Option<()> {
let entry = self.entry_for_path(path)?.clone();
if !entry.is_dir() {
return None;
}
let updated = FileEntry {
loaded: true,
..entry
};
self.insert_entry(updated);
Some(())
}
/// Populates a directory with its children from the filesystem.
/// This scans the directory and adds all immediate children.
#[cfg(feature = "local_fs")]
pub fn load_directory_children(
&mut self,
dir_path: &Path,
check_ignored: impl Fn(&Path) -> bool,
) -> std::io::Result<()> {
use std::fs;
// Mark directory as loaded
self.expand_directory(dir_path);
// Read directory contents
for entry in fs::read_dir(dir_path)? {
let entry = entry?;
let path = entry.path();
let is_dir = entry.file_type()?.is_dir();
let ignored = check_ignored(&path);
let file_entry = if is_dir {
FileEntry::directory(Arc::from(path.as_path()), ignored, false)
} else {
FileEntry::file(Arc::from(path.as_path()), ignored)
};
self.insert_entry(file_entry);
}
Ok(())
}
}
#[cfg(test)]
#[path = "snapshot_tests.rs"]
mod tests;
@@ -0,0 +1,91 @@
//! Iterator implementations for FileTreeSnapshot.
use std::{path::Path, sync::Arc};
use sum_tree::{Cursor, SeekBias};
use super::{FileEntry, FileTreeSnapshot, PathKey};
/// Iterator over direct children of a directory.
///
/// # How it works
///
/// Entries in the SumTree are sorted lexicographically by path. This means all entries
/// within a directory's subtree are **contiguous** in the sorted order:
///
/// ```text
/// Sorted entries for child_entries("/project/src/"):
/// ┌─────────────┬─────────────────────┬──────────────────────┬─────────────────────────────┬─────────────┐
/// │ /project/ │ /project/src/ │ /project/src/lib.rs │ /project/src/utils/ │ /project/z │
/// │ │ (skip: parent) │ ✓ yield (1 comp) │ ✓ yield (1 comp) │ (stop) │
/// │ │ ↓ │ │ │ │
/// │ │ cursor starts here │ │ /project/src/utils/helper.rs│ │
/// │ │ │ │ (skip: 2 components) │ │
/// └─────────────┴─────────────────────┴──────────────────────┴─────────────────────────────┴─────────────┘
/// ```
///
/// The iterator:
/// 1. Seeks to the parent path in O(log n)
/// 2. Skips the parent directory entry itself
/// 3. Iterates forward, yielding entries with exactly 1 path component after the parent prefix
/// 4. Skips deeper descendants (2+ components) — they'll be visited when their parent is expanded
/// 5. Stops when reaching an entry outside the parent's subtree
pub struct ChildEntriesIter<'a> {
cursor: Cursor<'a, FileEntry, PathKey, ()>,
parent_path: &'a Path,
done: bool,
}
impl<'a> ChildEntriesIter<'a> {
pub(super) fn new(snapshot: &'a FileTreeSnapshot, parent_path: &'a Path) -> Self {
let mut cursor = snapshot.entries_by_path.cursor::<PathKey, ()>();
let key = PathKey::new(Arc::from(parent_path));
cursor.seek(&key, SeekBias::Left);
// Skip past the parent directory itself
if cursor
.item()
.is_some_and(|e| e.path.as_ref() == parent_path)
{
cursor.next();
}
Self {
cursor,
parent_path,
done: false,
}
}
}
impl<'a> Iterator for ChildEntriesIter<'a> {
type Item = &'a FileEntry;
fn next(&mut self) -> Option<Self::Item> {
if self.done {
return None;
}
loop {
let entry = self.cursor.item()?;
// Stop if we've moved past the parent's subtree (lexicographically)
if !entry.path.starts_with(self.parent_path) {
self.done = true;
return None;
}
// Count path components after the parent prefix to determine depth
let relative = entry.path.strip_prefix(self.parent_path).ok()?;
let components: Vec<_> = relative.components().collect();
self.cursor.next();
// Yield only direct children (exactly 1 component after parent)
// Skip grandchildren and deeper (2+ components)
if components.len() == 1 {
return Some(entry);
}
}
}
}
+436
View File
@@ -0,0 +1,436 @@
//! Tests for the file tree snapshot module.
use std::{path::Path, sync::Arc};
use sum_tree::Item;
use super::*;
/// Helper to create a test snapshot from a list of path strings.
/// Paths ending in '/' are treated as directories, others as files.
/// Paths starting with '!' are marked as ignored.
fn test_snapshot(paths: &[&str]) -> FileTreeSnapshot {
let root = paths
.first()
.map(|p| p.trim_start_matches('!'))
.unwrap_or("/");
let root_path: Arc<Path> = Arc::from(Path::new(root.trim_end_matches('/')));
let mut snapshot = FileTreeSnapshot::new(root_path);
for path_str in paths {
let ignored = path_str.starts_with('!');
let path_str = path_str.trim_start_matches('!');
let is_dir = path_str.ends_with('/');
let path_str = path_str.trim_end_matches('/');
let path: Arc<Path> = Arc::from(Path::new(path_str));
let entry = if is_dir {
FileEntry::directory(path, ignored, true)
} else {
FileEntry::file(path, ignored)
};
snapshot.insert_entry(entry);
}
snapshot
}
// =============================================================================
// FileEntry Tests
// =============================================================================
#[test]
fn test_file_entry_creation() {
let file = FileEntry::file(Path::new("/src/main.rs"), false);
assert!(file.is_file());
assert!(!file.is_dir());
assert_eq!(file.extension(), Some("rs"));
assert!(!file.ignored);
assert!(file.loaded);
let dir = FileEntry::directory(Path::new("/src"), false, true);
assert!(dir.is_dir());
assert!(!dir.is_file());
assert_eq!(dir.extension(), None);
assert!(!dir.ignored);
assert!(dir.loaded);
}
#[test]
fn test_file_entry_ignored() {
let ignored_file = FileEntry::file(Path::new("/target/debug/main"), true);
assert!(ignored_file.ignored);
let ignored_dir = FileEntry::directory(Path::new("/target"), true, false);
assert!(ignored_dir.ignored);
assert!(!ignored_dir.loaded);
}
// =============================================================================
// FileEntrySummary Tests
// =============================================================================
#[test]
fn test_summary_for_visible_file() {
let entry = FileEntry::file(Path::new("/src/main.rs"), false);
let summary = entry.summary();
assert_eq!(summary.count, 1);
assert_eq!(summary.visible_count, 1);
assert_eq!(summary.file_count, 1);
assert_eq!(summary.visible_file_count, 1);
}
#[test]
fn test_summary_for_ignored_file() {
let entry = FileEntry::file(Path::new("/target/debug/main"), true);
let summary = entry.summary();
assert_eq!(summary.count, 1);
assert_eq!(summary.visible_count, 0);
assert_eq!(summary.file_count, 1);
assert_eq!(summary.visible_file_count, 0);
}
#[test]
fn test_summary_for_visible_directory() {
let entry = FileEntry::directory(Path::new("/src"), false, true);
let summary = entry.summary();
assert_eq!(summary.count, 1);
assert_eq!(summary.visible_count, 1);
assert_eq!(summary.file_count, 0);
assert_eq!(summary.visible_file_count, 0);
}
#[test]
fn test_summary_for_ignored_directory() {
let entry = FileEntry::directory(Path::new("/target"), true, false);
let summary = entry.summary();
assert_eq!(summary.count, 1);
assert_eq!(summary.visible_count, 0);
assert_eq!(summary.file_count, 0);
assert_eq!(summary.visible_file_count, 0);
}
#[test]
fn test_summary_add_assign() {
let mut summary1 = FileEntrySummary {
max_path: Arc::from(Path::new("/a")),
count: 2,
visible_count: 1,
file_count: 1,
visible_file_count: 1,
};
let summary2 = FileEntrySummary {
max_path: Arc::from(Path::new("/b")),
count: 3,
visible_count: 2,
file_count: 2,
visible_file_count: 1,
};
summary1 += &summary2;
assert_eq!(summary1.max_path.as_ref(), Path::new("/b"));
assert_eq!(summary1.count, 5);
assert_eq!(summary1.visible_count, 3);
assert_eq!(summary1.file_count, 3);
assert_eq!(summary1.visible_file_count, 2);
}
// =============================================================================
// PathKey Tests
// =============================================================================
#[test]
fn test_path_key_ordering() {
let key_a = PathKey::new(Path::new("/a"));
let key_b = PathKey::new(Path::new("/b"));
let key_aa = PathKey::new(Path::new("/a/a"));
assert!(key_a < key_aa);
assert!(key_aa < key_b);
assert!(key_a < key_b);
}
// =============================================================================
// FileTreeSnapshot Tests
// =============================================================================
#[test]
fn test_snapshot_with_root() {
let snapshot = FileTreeSnapshot::with_root(Path::new("/project"), false, true);
assert_eq!(snapshot.len(), 1);
let root = snapshot.entry_for_path(Path::new("/project")).unwrap();
assert!(root.is_dir());
assert!(root.loaded);
}
#[test]
fn test_insert_and_lookup() {
let snapshot = test_snapshot(&[
"/project/",
"/project/src/",
"/project/src/main.rs",
"/project/src/lib.rs",
]);
assert_eq!(snapshot.len(), 4);
let main_rs = snapshot.entry_for_path(Path::new("/project/src/main.rs"));
assert!(main_rs.is_some());
assert!(main_rs.unwrap().is_file());
let src = snapshot.entry_for_path(Path::new("/project/src"));
assert!(src.is_some());
assert!(src.unwrap().is_dir());
let missing = snapshot.entry_for_path(Path::new("/project/nonexistent"));
assert!(missing.is_none());
}
#[test]
fn test_remove_entry() {
let mut snapshot = test_snapshot(&["/project/", "/project/src/", "/project/src/main.rs"]);
assert_eq!(snapshot.len(), 3);
snapshot.remove_entry(Path::new("/project/src/main.rs"));
assert_eq!(snapshot.len(), 2);
assert!(snapshot
.entry_for_path(Path::new("/project/src/main.rs"))
.is_none());
assert!(snapshot.entry_for_path(Path::new("/project/src")).is_some());
}
#[test]
fn test_child_entries() {
let snapshot = test_snapshot(&[
"/project/",
"/project/src/",
"/project/src/main.rs",
"/project/src/lib.rs",
"/project/tests/",
"/project/tests/test.rs",
"/project/Cargo.toml",
]);
let root_children: Vec<_> = snapshot.child_entries(Path::new("/project")).collect();
assert_eq!(root_children.len(), 3);
let child_paths: Vec<_> = root_children.iter().map(|e| e.path.as_ref()).collect();
assert!(child_paths.contains(&Path::new("/project/src")));
assert!(child_paths.contains(&Path::new("/project/tests")));
assert!(child_paths.contains(&Path::new("/project/Cargo.toml")));
// Should not include nested entries
assert!(!child_paths.contains(&Path::new("/project/src/main.rs")));
let src_children: Vec<_> = snapshot.child_entries(Path::new("/project/src")).collect();
assert_eq!(src_children.len(), 2);
}
#[test]
fn test_child_entries_empty_directory() {
let snapshot = test_snapshot(&["/project/", "/project/empty/"]);
let children: Vec<_> = snapshot
.child_entries(Path::new("/project/empty"))
.collect();
assert!(children.is_empty());
}
// =============================================================================
// Lazy Loading Tests
// =============================================================================
#[test]
fn test_is_parent_loaded_root() {
let snapshot = FileTreeSnapshot::with_root(Path::new("/project"), false, true);
assert!(snapshot.is_parent_loaded(Path::new("/project/src")));
}
#[test]
fn test_is_parent_loaded_unloaded_directory() {
let mut snapshot = FileTreeSnapshot::new(Path::new("/project"));
snapshot.insert_entry(FileEntry::directory(Path::new("/project"), false, true));
snapshot.insert_entry(FileEntry::directory(
Path::new("/project/collapsed"),
false,
false,
));
// Parent /project is loaded
assert!(snapshot.is_parent_loaded(Path::new("/project/collapsed")));
// Parent /project/collapsed is NOT loaded
assert!(!snapshot.is_parent_loaded(Path::new("/project/collapsed/child.txt")));
}
#[test]
fn test_is_parent_loaded_nested() {
let snapshot = test_snapshot(&["/project/", "/project/src/", "/project/src/nested/"]);
assert!(snapshot.is_parent_loaded(Path::new("/project/src")));
assert!(snapshot.is_parent_loaded(Path::new("/project/src/nested")));
assert!(snapshot.is_parent_loaded(Path::new("/project/src/nested/file.rs")));
}
// =============================================================================
// Edge Cases
// =============================================================================
#[test]
fn test_single_entry() {
let mut snapshot = FileTreeSnapshot::new(Path::new("/"));
snapshot.insert_entry(FileEntry::file(Path::new("/only_file.txt"), false));
assert_eq!(snapshot.len(), 1);
assert!(snapshot
.entry_for_path(Path::new("/only_file.txt"))
.is_some());
}
#[test]
fn test_update_existing_entry() {
let mut snapshot = test_snapshot(&["/project/", "/project/file.txt"]);
// Update the file to be ignored
let updated = FileEntry::file(Path::new("/project/file.txt"), true);
snapshot.insert_entry(updated);
assert_eq!(snapshot.len(), 2); // Should not duplicate
let entry = snapshot
.entry_for_path(Path::new("/project/file.txt"))
.unwrap();
assert!(entry.ignored);
}
// =============================================================================
// Filesystem Event Handling Tests
// =============================================================================
#[test]
fn test_handle_added_in_loaded_directory() {
let mut snapshot = test_snapshot(&["/project/", "/project/src/"]);
// Add a file to a loaded directory
let result = snapshot.handle_added(Path::new("/project/src/new_file.rs"), false, false);
assert!(result);
assert!(snapshot
.entry_for_path(Path::new("/project/src/new_file.rs"))
.is_some());
}
#[test]
fn test_handle_added_in_unloaded_directory() {
let mut snapshot = FileTreeSnapshot::new(Path::new("/project"));
snapshot.insert_entry(FileEntry::directory(Path::new("/project"), false, true));
snapshot.insert_entry(FileEntry::directory(
Path::new("/project/collapsed"),
false,
false,
));
// Try to add a file to an unloaded directory - should fail
let result = snapshot.handle_added(Path::new("/project/collapsed/file.rs"), false, false);
assert!(!result);
assert!(snapshot
.entry_for_path(Path::new("/project/collapsed/file.rs"))
.is_none());
}
#[test]
fn test_handle_removed() {
let mut snapshot = test_snapshot(&["/project/", "/project/file.txt"]);
let result = snapshot.handle_removed(Path::new("/project/file.txt"));
assert!(result);
assert!(snapshot
.entry_for_path(Path::new("/project/file.txt"))
.is_none());
}
#[test]
fn test_handle_removed_nonexistent() {
let mut snapshot = test_snapshot(&["/project/"]);
let result = snapshot.handle_removed(Path::new("/project/nonexistent.txt"));
assert!(!result);
}
/// Test helper: Handles a file/directory being renamed/moved.
/// Returns true if the rename was processed.
fn handle_renamed(
snapshot: &mut FileTreeSnapshot,
old_path: &Path,
new_path: &Path,
is_dir: bool,
ignored: bool,
) -> bool {
let old_loaded = snapshot.is_parent_loaded(old_path);
let new_loaded = snapshot.is_parent_loaded(new_path);
// Remove from old location if parent was loaded
if old_loaded {
snapshot.remove_entry(old_path);
}
// Add to new location if parent is loaded
if new_loaded {
let entry = if is_dir {
FileEntry::directory(Arc::from(new_path), ignored, false)
} else {
FileEntry::file(Arc::from(new_path), ignored)
};
snapshot.insert_entry(entry);
}
old_loaded || new_loaded
}
#[test]
fn test_handle_renamed() {
let mut snapshot = test_snapshot(&["/project/", "/project/old_name.txt"]);
let result = handle_renamed(
&mut snapshot,
Path::new("/project/old_name.txt"),
Path::new("/project/new_name.txt"),
false,
false,
);
assert!(result);
assert!(snapshot
.entry_for_path(Path::new("/project/old_name.txt"))
.is_none());
assert!(snapshot
.entry_for_path(Path::new("/project/new_name.txt"))
.is_some());
}
#[test]
fn test_expand_directory() {
let mut snapshot = FileTreeSnapshot::new(Path::new("/project"));
snapshot.insert_entry(FileEntry::directory(Path::new("/project"), false, true));
snapshot.insert_entry(FileEntry::directory(
Path::new("/project/collapsed"),
false,
false,
));
let entry = snapshot
.entry_for_path(Path::new("/project/collapsed"))
.unwrap();
assert!(!entry.loaded);
snapshot.expand_directory(Path::new("/project/collapsed"));
let entry = snapshot
.entry_for_path(Path::new("/project/collapsed"))
.unwrap();
assert!(entry.loaded);
}
File diff suppressed because it is too large Load Diff
+292
View File
@@ -0,0 +1,292 @@
//! Module for utlities related to editing items in the file tree.
#[cfg(test)]
#[path = "editing_tests.rs"]
mod tests;
use repo_metadata::file_tree_store::FileTreeEntryState;
use repo_metadata::{FileMetadata, FileTreeEntry};
use std::cmp::Ordering;
use std::sync::Arc;
use warp_util::standardized_path::StandardizedPath;
use warpui::{elements::MouseStateHandle, ViewContext};
use super::{FileTreeIdentifier, FileTreeItem, FileTreeView};
use crate::{
code::file_tree::{
view::{PendingEdit, PendingEditKind},
FileTreeEvent,
},
send_telemetry_from_ctx,
server::telemetry::TelemetryEvent,
};
/// Custom ordering function for items in the file tree.
///
/// Directories are ordered first, sorted alphabetically.
/// Files are ordered second, sorted alphabetically.
/// Within each group, dotfiles (entries starting with a dot) are ordered first.
pub(super) fn sort_entries_for_file_tree(
entry_1: &StandardizedPath,
entry_2: &StandardizedPath,
entry_map: &FileTreeEntry,
) -> Ordering {
use std::cmp::Ordering;
// Entries missing from the map sort before present entries, and compare
// equal to each other. Using the same `Ordering` on both sides would
// violate antisymmetry and cause `sorted_by` to panic with
// "user-provided comparison function does not correctly implement a total order".
let (entry_1, entry_2) = match (entry_map.get(entry_1), entry_map.get(entry_2)) {
(None, None) => return Ordering::Equal,
(None, Some(_)) => return Ordering::Less,
(Some(_), None) => return Ordering::Greater,
(Some(e1), Some(e2)) => (e1, e2),
};
let is_dir_1 = matches!(entry_1, FileTreeEntryState::Directory(_));
let is_dir_2 = matches!(entry_2, FileTreeEntryState::Directory(_));
// Order directories before any files.
match (is_dir_1, is_dir_2) {
(true, false) => return Ordering::Less,
(false, true) => return Ordering::Greater,
// Both are same type, continue with alphabetical sort.
_ => {}
}
// Same antisymmetry requirement for missing file names.
let (name_1, name_2) = match (entry_1.path().file_name(), entry_2.path().file_name()) {
(None, None) => return Ordering::Equal,
(None, Some(_)) => return Ordering::Less,
(Some(_), None) => return Ordering::Greater,
(Some(n1), Some(n2)) => (n1, n2),
};
let starts_with_dot_1 = name_1.starts_with('.');
let starts_with_dot_2 = name_2.starts_with('.');
// Items starting with "." come first.
match (starts_with_dot_1, starts_with_dot_2) {
(true, false) => Ordering::Less,
(false, true) => Ordering::Greater,
_ => name_1.cmp(name_2),
}
}
impl FileTreeView {
/// Creates a new file below the directory at the given identifier.
pub(super) fn create_new_file(&mut self, id: &FileTreeIdentifier, ctx: &mut ViewContext<Self>) {
let Some(root_dir) = self.root_directories.get_mut(&id.root) else {
return;
};
let (path, depth) = match root_dir.items.get(id.index) {
Some(FileTreeItem::File { .. }) => {
log::warn!("Cannot create a new file below a file");
return;
}
Some(FileTreeItem::DirectoryHeader {
directory, depth, ..
}) => (directory.path.clone(), *depth),
_ => return,
};
// Ensure the parent directory is expanded before creating a file beneath it.
if !self.is_folder_expanded(&id.root, &path) {
self.toggle_folder_expansion(&id.root, &path, ctx);
}
// Create a dummy FileTreeItem for the file we are about to create--we'll replace
// this with something real once the user types in the actual file.
let new_item_index = id.index + 1;
let Some(root_dir) = self.root_directories.get_mut(&id.root) else {
return;
};
root_dir.items.insert(
new_item_index,
FileTreeItem::File {
metadata: FileMetadata::from_standardized(path.join("new_file"), false).into(),
depth: depth + 1,
mouse_state_handle: MouseStateHandle::default(),
draggable_state: warpui::elements::DraggableState::default(),
},
);
// Ensure the new item we just created is selected.
let new_id = FileTreeIdentifier {
root: id.root.clone(),
index: new_item_index,
};
self.select_id(&new_id, ctx);
// Ensure the editor is focused.
ctx.focus(&self.editor_view);
self.pending_edit = Some(PendingEdit {
id: new_id,
kind: PendingEditKind::CreateNewFile,
});
}
/// Starts a rename edit on the item at the given identifier.
pub(super) fn start_rename(&mut self, id: &FileTreeIdentifier, ctx: &mut ViewContext<Self>) {
let Some(root_dir) = self.root_directories.get(&id.root) else {
return;
};
let Some(item) = root_dir.items.get(id.index) else {
return;
};
// Prefill the editor with the current file or directory name.
let current_name = item
.path()
.file_name()
.map(|s| s.to_owned())
.unwrap_or_default();
self.pending_edit = Some(PendingEdit {
id: id.clone(),
kind: PendingEditKind::RenameExisting,
});
self.editor_view.update(ctx, |view, ctx| {
view.set_buffer_text(&current_name, ctx);
});
ctx.focus(&self.editor_view);
}
/// Commits a pending edit to the file tree.
pub(super) fn commit_pending_edit(&mut self, ctx: &mut ViewContext<Self>) {
let Some(pending_edit) = self.pending_edit.take() else {
return;
};
let file_tree_id = pending_edit.id.clone();
let buffer_content = self.editor_view.as_ref(ctx).buffer_text(ctx);
self.editor_view.update(ctx, |view, ctx| {
view.clear_buffer(ctx);
});
match pending_edit.kind {
PendingEditKind::CreateNewFile => {
let new_entry = {
let Some(root_dir) = self.root_directories.get_mut(&file_tree_id.root) else {
return;
};
let Some(item) = root_dir.items.get_mut(file_tree_id.index) else {
return;
};
if let FileTreeItem::File { metadata, .. } = item {
let mut new_std = (*metadata.path).clone();
new_std.set_file_name(&buffer_content);
let local_path = new_std.to_local_path_lossy();
metadata.path = Arc::new(new_std);
if let Err(e) = std::fs::File::create_new(&local_path) {
log::warn!("Failed to create file: {e}");
return;
}
send_telemetry_from_ctx!(TelemetryEvent::FileTreeItemCreated, ctx);
FileTreeEntryState::File(metadata.clone())
} else {
return;
}
};
if let Some(root_dir) = self.root_directories.get_mut(&file_tree_id.root) {
// Ensure the file tree has the new item we've just created.
Self::insert_entry(&mut root_dir.entry, new_entry);
}
self.open_in_new_pane(&file_tree_id, ctx);
self.rebuild_flattened_items();
}
PendingEditKind::RenameExisting => {
let Some(root_dir) = self.root_directories.get(&file_tree_id.root) else {
return;
};
let Some(item) = root_dir.items.get(file_tree_id.index) else {
return;
};
if buffer_content.is_empty() {
return;
}
let old_std_path = item.path().clone();
let mut new_std_path = old_std_path.clone();
new_std_path.set_file_name(&buffer_content);
let old_path = old_std_path.to_local_path_lossy();
let new_path = new_std_path.to_local_path_lossy();
if let Err(e) = std::fs::rename(&old_path, &new_path) {
log::warn!(
"Failed to rename {} -> {}: {e}",
old_path.display(),
new_path.display()
);
return;
}
// Update the in-memory model immediately so the UI reflects the change without delay.
if let Some(root_dir) = self.root_directories.get_mut(&file_tree_id.root) {
root_dir.entry.rename_path(&old_std_path, &new_std_path);
}
// Emit event to notify workspace that a file was renamed
ctx.emit(FileTreeEvent::FileRenamed {
old_path: old_path.clone(),
new_path: new_path.clone(),
});
// Rebuild and select the renamed item using its FileTreeIdentifier
self.rebuild_flatten_items_and_select_path(Some(&file_tree_id), None);
ctx.notify();
}
}
}
/// Cancels a pending edit and discards any changes.
pub(super) fn cancel_pending_edit(&mut self, ctx: &mut ViewContext<Self>) {
if let Some(pending_edit) = self.pending_edit.take() {
let id = &pending_edit.id;
if self.selected_item.as_ref() == Some(id) {
self.selected_item = None;
}
self.editor_view.update(ctx, |view, ctx| {
view.clear_buffer(ctx);
});
// Only remove placeholder in the create-new-file flow.
if pending_edit.kind == PendingEditKind::CreateNewFile {
if let Some(root_dir) = self.root_directories.get_mut(&id.root) {
root_dir.items.remove(id.index);
}
}
}
ctx.notify();
}
/// Inserts a new entry into the tree.
fn insert_entry(root_entry: &mut FileTreeEntry, child_entry: FileTreeEntryState) {
let Some(parent) = child_entry.path().parent() else {
return;
};
root_entry.insert_child_state(&parent, child_entry);
}
pub(super) fn handle_pending_edit(&mut self, ctx: &mut ViewContext<Self>) {
if self.pending_edit.is_none() {
return;
};
let editor_contents = self.editor_view.as_ref(ctx).buffer_text(ctx);
// If the editor is empty and the editor was dismissed, cancel the editor.
// Otherwise commit the editor. This matches VSCode's behavior.
if editor_contents.is_empty() {
self.cancel_pending_edit(ctx);
} else {
self.commit_pending_edit(ctx);
}
}
}
@@ -0,0 +1,74 @@
use std::sync::Arc;
use repo_metadata::file_tree_store::{FileTreeDirectoryEntryState, FileTreeEntryState};
use repo_metadata::{FileMetadata, FileTreeEntry};
use warp_util::standardized_path::StandardizedPath;
use super::sort_entries_for_file_tree;
fn std_path(s: &str) -> StandardizedPath {
StandardizedPath::try_new(s).expect("test path should be valid")
}
fn dir_state(path: &str) -> FileTreeEntryState {
FileTreeEntryState::Directory(FileTreeDirectoryEntryState {
path: Arc::new(std_path(path)),
ignored: false,
loaded: true,
})
}
fn file_state(path: &str) -> FileTreeEntryState {
FileTreeEntryState::File(FileMetadata::from_standardized(std_path(path), false).into())
}
#[test]
fn sort_entries_for_file_tree_is_antisymmetric_for_missing_entries() {
let root = std_path("/repo");
let mut entry = FileTreeEntry::new_for_directory(Arc::new(root.clone()));
entry.insert_child_state(&root, dir_state("/repo/src"));
entry.insert_child_state(&root, file_state("/repo/README.md"));
let paths = [
std_path("/repo/src"), // present (directory)
std_path("/repo/README.md"), // present (file)
std_path("/repo/ghost_a"), // missing
std_path("/repo/ghost_b"), // missing
];
for a in &paths {
for b in &paths {
let ab = sort_entries_for_file_tree(a, b, &entry);
let ba = sort_entries_for_file_tree(b, a, &entry);
assert_eq!(
ab.reverse(),
ba,
"comparator not antisymmetric for ({}, {}): cmp(a,b) = {:?}, cmp(b,a) = {:?}",
a.as_str(),
b.as_str(),
ab,
ba,
);
}
}
}
#[test]
fn sort_entries_for_file_tree_sorts_without_panicking_on_missing_children() {
let root = std_path("/repo");
let mut entry = FileTreeEntry::new_for_directory(Arc::new(root.clone()));
entry.insert_child_state(&root, dir_state("/repo/src"));
// Multiple missing entries are required to reliably trigger the sort's
// total-order violation check.
let mut paths = [
std_path("/repo/src"),
std_path("/repo/ghost_a"),
std_path("/repo/ghost_b"),
std_path("/repo/ghost_c"),
std_path("/repo/ghost_d"),
std_path("/repo/ghost_e"),
];
paths.sort_by(|a, b| sort_entries_for_file_tree(a, b, &entry));
}
+73
View File
@@ -0,0 +1,73 @@
use warpui::elements::{DraggableState, MouseStateHandle};
use super::FileTreeItem;
use crate::code::icon_from_file_path;
use crate::ui_components::item_highlight::ImageOrIcon;
use crate::{appearance::Appearance, ui_components::icons::Icon};
impl FileTreeItem {
pub(super) fn to_render_state(
&self,
is_expanded: Option<bool>,
appearance: &Appearance,
) -> RenderState {
match self {
FileTreeItem::File {
metadata,
mouse_state_handle,
depth,
draggable_state,
} => {
let display_name = metadata
.path
.file_name()
.map(ToOwned::to_owned)
.unwrap_or_else(|| String::from("File"));
let icon_from_file_path =
icon_from_file_path(metadata.path.as_str(), appearance).map(ImageOrIcon::Image);
RenderState {
display_name,
icon: icon_from_file_path.unwrap_or(ImageOrIcon::Icon(Icon::File)),
is_expanded,
depth: *depth,
mouse_state: mouse_state_handle.clone(),
draggable_state: draggable_state.clone(),
is_ignored: metadata.ignored,
}
}
FileTreeItem::DirectoryHeader {
directory,
mouse_state_handle,
depth,
draggable_state,
} => {
let display_name = directory
.path
.file_name()
.map(ToOwned::to_owned)
.unwrap_or_else(|| String::from("Folder"));
RenderState {
display_name,
icon: ImageOrIcon::Icon(Icon::Folder),
is_expanded,
depth: *depth,
mouse_state: mouse_state_handle.clone(),
draggable_state: draggable_state.clone(),
is_ignored: directory.ignored,
}
}
}
}
}
pub(super) struct RenderState {
pub display_name: String,
pub icon: ImageOrIcon,
pub is_expanded: Option<bool>,
pub depth: usize,
pub mouse_state: MouseStateHandle,
pub draggable_state: DraggableState,
pub is_ignored: bool,
}
+987
View File
@@ -0,0 +1,987 @@
use std::sync::Arc;
use repo_metadata::entry::{DirectoryEntry, Entry, FileMetadata};
use repo_metadata::file_tree_store::FileTreeState;
use repo_metadata::local_model::IndexedRepoState;
use repo_metadata::repositories::DetectedRepositories;
use repo_metadata::watcher::DirectoryWatcher;
use repo_metadata::RepoMetadataModel;
use virtual_fs::{Stub, VirtualFS};
use warp_core::ui::appearance::Appearance;
use warpui::{platform::WindowStyle, App, ModelHandle};
use crate::auth::AuthStateProvider;
use crate::server::server_api::{team::MockTeamClient, workspace::MockWorkspaceClient};
use crate::settings_view::keybindings::KeybindingChangedNotifier;
use crate::test_util::settings::initialize_settings_for_tests;
use crate::vim_registers::VimRegisters;
use crate::workspace::sync_inputs::SyncedInputState;
use crate::workspace::ToastStack;
use crate::workspaces::user_workspaces::UserWorkspaces;
use super::FileTreeView;
fn std_path(path: &std::path::Path) -> warp_util::standardized_path::StandardizedPath {
warp_util::standardized_path::StandardizedPath::try_from_local(path).unwrap()
}
fn initialize_app(
app: &mut App,
) -> (
ModelHandle<DetectedRepositories>,
ModelHandle<RepoMetadataModel>,
) {
initialize_settings_for_tests(app);
app.add_singleton_model(|_| Appearance::mock());
app.add_singleton_model(|_| ToastStack);
app.add_singleton_model(|_| SyncedInputState::mock());
app.add_singleton_model(|_| VimRegisters::new());
app.add_singleton_model(|_| KeybindingChangedNotifier::mock());
app.add_singleton_model(|_| AuthStateProvider::new_for_test());
let team_client = Arc::new(MockTeamClient::new());
let workspace_client = Arc::new(MockWorkspaceClient::new());
app.add_singleton_model(|ctx| {
UserWorkspaces::mock(team_client.clone(), workspace_client.clone(), vec![], ctx)
});
let detected_repositories = app.add_singleton_model(|_| DetectedRepositories::default());
let repository_metadata_model = app.add_singleton_model(RepoMetadataModel::new);
(detected_repositories, repository_metadata_model)
}
fn build_repo_state(repo_root: &std::path::Path) -> FileTreeState {
let source_file = Entry::File(FileMetadata::new(
repo_root.join("packages/app/src/main.rs"),
false,
));
let src_dir = Entry::Directory(DirectoryEntry {
path: warp_util::standardized_path::StandardizedPath::try_from_local(
&repo_root.join("packages/app/src"),
)
.unwrap(),
children: vec![source_file],
ignored: false,
loaded: true,
});
let app_dir = Entry::Directory(DirectoryEntry {
path: warp_util::standardized_path::StandardizedPath::try_from_local(
&repo_root.join("packages/app"),
)
.unwrap(),
children: vec![src_dir],
ignored: false,
loaded: true,
});
let packages_dir = Entry::Directory(DirectoryEntry {
path: warp_util::standardized_path::StandardizedPath::try_from_local(
&repo_root.join("packages"),
)
.unwrap(),
children: vec![app_dir],
ignored: false,
loaded: true,
});
let root = Entry::Directory(DirectoryEntry {
path: std_path(repo_root),
children: vec![packages_dir],
ignored: false,
loaded: true,
});
FileTreeState::new(root, vec![], None)
}
fn build_repo_state_with_unloaded_directory(repo_root: &std::path::Path) -> FileTreeState {
let unloaded_src_dir = Entry::Directory(DirectoryEntry {
path: warp_util::standardized_path::StandardizedPath::try_from_local(
&repo_root.join("src"),
)
.unwrap(),
children: vec![],
ignored: false,
loaded: false,
});
let root = Entry::Directory(DirectoryEntry {
path: std_path(repo_root),
children: vec![unloaded_src_dir],
ignored: false,
loaded: true,
});
FileTreeState::new(root, vec![], None)
}
#[test]
fn repo_transition_unregisters_lazy_loaded_path() {
VirtualFS::test("file_tree_repo_transition", |dirs, mut vfs| {
vfs.mkdir("repo/.git/objects")
.mkdir("repo/packages/app/src")
.with_files(vec![
Stub::FileWithContent("repo/.git/HEAD", "ref: refs/heads/main"),
Stub::FileWithContent("repo/.git/config", "[core]\n\trepositoryformatversion = 0"),
Stub::FileWithContent("repo/packages/app/src/main.rs", "fn main() {}\n"),
]);
let repo_root = dirs.tests().join("repo");
let displayed_root = repo_root.join("packages/app");
let canonical_repo_root =
warp_util::standardized_path::StandardizedPath::from_local_canonicalized(&repo_root)
.unwrap();
App::test((), |mut app| async move {
let (detected_repositories, repository_metadata_model) = initialize_app(&mut app);
let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
detected_repositories.update(&mut app, |repositories, _ctx| {
repositories.insert_test_repo_root(canonical_repo_root.clone());
});
file_tree_view.update(&mut app, |view, ctx| {
view.set_is_active(true, ctx);
view.set_root_directories(vec![displayed_root.clone()], ctx);
});
file_tree_view.read(&app, |view, _ctx| {
assert!(view.registered_lazy_loaded_paths.contains(
&warp_util::standardized_path::StandardizedPath::try_from_local(
&displayed_root
)
.unwrap()
));
let displayed_std =
warp_util::standardized_path::StandardizedPath::try_from_local(&displayed_root)
.unwrap();
assert_eq!(
view.root_directories
.get(&displayed_std)
.map(|root_dir| root_dir.entry.root_directory().to_local_path_lossy()),
Some(displayed_root.clone())
);
});
repository_metadata_model.read(&app, |model, ctx| {
assert!(model.is_lazy_loaded_path(
&warp_util::standardized_path::StandardizedPath::try_from_local(
&displayed_root
)
.unwrap(),
ctx
));
});
repository_metadata_model.update(&mut app, |model, ctx| {
model.insert_test_state(canonical_repo_root, build_repo_state(&repo_root), ctx);
});
file_tree_view.update(&mut app, |view, ctx| {
view.set_root_directories(vec![displayed_root.clone()], ctx);
});
file_tree_view.read(&app, |view, _ctx| {
let displayed_std =
warp_util::standardized_path::StandardizedPath::try_from_local(&displayed_root)
.unwrap();
let repo_std =
warp_util::standardized_path::StandardizedPath::try_from_local(&repo_root)
.unwrap();
assert!(!view.registered_lazy_loaded_paths.contains(&displayed_std));
assert_eq!(view.root_for_path(&displayed_std), Some(repo_std.clone()));
assert_eq!(
view.root_directories
.get(&displayed_std)
.map(|root_dir| (**root_dir.entry.root_directory()).clone()),
Some(repo_std)
);
});
repository_metadata_model.read(&app, |model, ctx| {
assert!(!model.is_lazy_loaded_path(
&warp_util::standardized_path::StandardizedPath::try_from_local(
&displayed_root
)
.unwrap(),
ctx
));
});
});
});
}
#[test]
fn repo_backed_unloaded_directory_loads_through_model() {
VirtualFS::test("file_tree_repo_backed_load", |dirs, mut vfs| {
vfs.mkdir("repo/.git/objects")
.mkdir("repo/src/nested")
.with_files(vec![
Stub::FileWithContent("repo/.git/HEAD", "ref: refs/heads/main"),
Stub::FileWithContent(
"repo/.git/config",
"[core]
\trepositoryformatversion = 0",
),
Stub::FileWithContent(
"repo/src/nested/main.rs",
"fn main() {}
",
),
]);
let repo_root = dirs.tests().join("repo");
let src_dir = repo_root.join("src");
let nested_dir = repo_root.join("src/nested");
let source_file = repo_root.join("src/nested/main.rs");
let canonical_repo_root =
warp_util::standardized_path::StandardizedPath::from_local_canonicalized(&repo_root)
.unwrap();
App::test((), |mut app| async move {
let (detected_repositories, repository_metadata_model) = initialize_app(&mut app);
let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
detected_repositories.update(&mut app, |repositories, _ctx| {
repositories.insert_test_repo_root(canonical_repo_root.clone());
});
repository_metadata_model.update(&mut app, |model, ctx| {
model.insert_test_state(
canonical_repo_root,
build_repo_state_with_unloaded_directory(&repo_root),
ctx,
);
});
file_tree_view.update(&mut app, |view, ctx| {
view.set_is_active(true, ctx);
view.set_root_directories(vec![repo_root.clone()], ctx);
});
file_tree_view.read(&app, |view, _ctx| {
assert!(!view
.root_directories
.get(
&warp_util::standardized_path::StandardizedPath::try_from_local(&repo_root)
.unwrap()
)
.is_some_and(|root_dir| root_dir.entry.contains(
&warp_util::standardized_path::StandardizedPath::try_from_local(
&source_file
)
.unwrap()
)));
});
file_tree_view.update(&mut app, |view, ctx| {
view.ensure_loaded_path(
&warp_util::standardized_path::StandardizedPath::try_from_local(&repo_root)
.unwrap(),
&warp_util::standardized_path::StandardizedPath::try_from_local(&src_dir)
.unwrap(),
ctx,
);
});
file_tree_view.read(&app, |view, _ctx| {
assert!(view
.root_directories
.get(
&warp_util::standardized_path::StandardizedPath::try_from_local(&repo_root)
.unwrap()
)
.is_some_and(|root_dir| root_dir.entry.contains(
&warp_util::standardized_path::StandardizedPath::try_from_local(
&nested_dir
)
.unwrap()
)));
});
file_tree_view.update(&mut app, |view, ctx| {
view.ensure_loaded_path(
&warp_util::standardized_path::StandardizedPath::try_from_local(&repo_root)
.unwrap(),
&warp_util::standardized_path::StandardizedPath::try_from_local(&nested_dir)
.unwrap(),
ctx,
);
});
file_tree_view.read(&app, |view, _ctx| {
assert!(view
.root_directories
.get(
&warp_util::standardized_path::StandardizedPath::try_from_local(&repo_root)
.unwrap()
)
.is_some_and(|root_dir| root_dir.entry.contains(
&warp_util::standardized_path::StandardizedPath::try_from_local(
&source_file
)
.unwrap()
)));
});
repository_metadata_model.read(&app, |model, ctx| {
assert!(!model.is_lazy_loaded_path(
&warp_util::standardized_path::StandardizedPath::try_from_local(&repo_root)
.unwrap(),
ctx
));
let id = repo_metadata::RepositoryIdentifier::local(
warp_util::standardized_path::StandardizedPath::try_from_local(&repo_root)
.unwrap(),
);
assert!(model.get_repository(&id, ctx).is_some_and(|state| {
state.entry.contains(
&warp_util::standardized_path::StandardizedPath::try_from_local(
&source_file,
)
.unwrap(),
)
}));
});
});
});
}
#[test]
fn pending_repository_root_does_not_register_lazy_loaded_path() {
VirtualFS::test("file_tree_pending_repo_root", |dirs, mut vfs| {
vfs.mkdir("repo/.git/objects").with_files(vec![
Stub::FileWithContent("repo/.git/HEAD", "ref: refs/heads/main"),
Stub::FileWithContent("repo/.git/config", "[core]\n\trepositoryformatversion = 0"),
]);
let repo_root = dirs.tests().join("repo");
let canonical_repo_root =
warp_util::standardized_path::StandardizedPath::from_local_canonicalized(&repo_root)
.unwrap();
App::test((), |mut app| async move {
let (detected_repositories, repository_metadata_model) = initialize_app(&mut app);
let directory_watcher = app.add_singleton_model(DirectoryWatcher::new);
let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
let repository_handle = directory_watcher.update(&mut app, |watcher, ctx| {
watcher
.add_directory(canonical_repo_root.clone(), ctx)
.unwrap()
});
detected_repositories.update(&mut app, |repositories, _ctx| {
repositories.insert_test_repo_root(canonical_repo_root.clone());
});
repository_metadata_model.update(&mut app, |model, ctx| {
model.index_directory(repository_handle, ctx).unwrap();
});
repository_metadata_model.read(&app, |model, ctx| {
let id = repo_metadata::RepositoryIdentifier::local(
warp_util::standardized_path::StandardizedPath::try_from_local(&repo_root)
.unwrap(),
);
assert!(matches!(
model.repository_state(&id, ctx),
Some(IndexedRepoState::Pending)
));
});
file_tree_view.update(&mut app, |view, ctx| {
view.set_is_active(true, ctx);
view.set_root_directories(vec![repo_root.clone()], ctx);
});
file_tree_view.read(&app, |view, _ctx| {
assert!(!view.registered_lazy_loaded_paths.contains(
&warp_util::standardized_path::StandardizedPath::try_from_local(&repo_root)
.unwrap()
));
});
repository_metadata_model.read(&app, |model, ctx| {
assert!(!model.is_lazy_loaded_path(
&warp_util::standardized_path::StandardizedPath::try_from_local(&repo_root)
.unwrap(),
ctx
));
let id = repo_metadata::RepositoryIdentifier::local(
warp_util::standardized_path::StandardizedPath::try_from_local(&repo_root)
.unwrap(),
);
assert!(matches!(
model.repository_state(&id, ctx),
Some(IndexedRepoState::Pending)
));
});
});
});
}
#[test]
fn failed_lazy_loaded_path_registration_is_retried() {
VirtualFS::test("file_tree_lazy_loaded_path_retry", |dirs, mut vfs| {
let displayed_root = dirs.tests().join("late_dir");
App::test((), |mut app| async move {
let (_detected_repositories, repository_metadata_model) = initialize_app(&mut app);
let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
file_tree_view.update(&mut app, |view, ctx| {
view.set_is_active(true, ctx);
view.set_root_directories(vec![displayed_root.clone()], ctx);
});
file_tree_view.read(&app, |view, _ctx| {
assert!(!view.registered_lazy_loaded_paths.contains(
&warp_util::standardized_path::StandardizedPath::try_from_local(
&displayed_root
)
.unwrap()
));
});
repository_metadata_model.read(&app, |model, ctx| {
assert!(!model.is_lazy_loaded_path(
&warp_util::standardized_path::StandardizedPath::try_from_local(
&displayed_root
)
.unwrap(),
ctx
));
});
vfs.mkdir("late_dir")
.with_files(vec![Stub::FileWithContent("late_dir/file.txt", "content")]);
file_tree_view.update(&mut app, |view, ctx| {
view.set_root_directories(vec![displayed_root.clone()], ctx);
});
file_tree_view.read(&app, |view, _ctx| {
assert!(view.registered_lazy_loaded_paths.contains(&std_path(&displayed_root)));
assert!(matches!(
view.root_directories.get(&std_path(&displayed_root)).map(|root_dir| &root_dir.entry),
Some(entry)
if entry.contains(&std_path(&displayed_root.join("file.txt")))
));
});
repository_metadata_model.read(&app, |model, ctx| {
assert!(model.is_lazy_loaded_path(
&warp_util::standardized_path::StandardizedPath::try_from_local(
&displayed_root
)
.unwrap(),
ctx
));
});
});
});
}
// ── Ancestor grouping (APP-4106) ────────────────────────────────────
#[test]
fn sibling_roots_are_preserved() {
VirtualFS::test("file_tree_sibling_roots", |dirs, mut vfs| {
vfs.mkdir("tree/a").mkdir("tree/b").with_files(vec![
Stub::FileWithContent("tree/a/x.txt", "x"),
Stub::FileWithContent("tree/b/y.txt", "y"),
]);
let a = dirs.tests().join("tree/a");
let b = dirs.tests().join("tree/b");
App::test((), |mut app| async move {
let _ = initialize_app(&mut app);
let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
file_tree_view.update(&mut app, |view, ctx| {
view.set_is_active(true, ctx);
view.set_root_directories(vec![a.clone(), b.clone()], ctx);
});
file_tree_view.read(&app, |view, _ctx| {
assert_eq!(view.displayed_directories, vec![std_path(&a), std_path(&b)]);
});
});
});
}
#[test]
fn auto_expand_overrides_selection_when_most_recent_root_changes() {
VirtualFS::test(
"file_tree_auto_expand_overrides_on_new_root",
|dirs, mut vfs| {
vfs.mkdir("code/foo").mkdir("other").with_files(vec![
Stub::FileWithContent("code/foo/file.txt", "x"),
Stub::FileWithContent("other/file.txt", "y"),
]);
let code = dirs.tests().join("code");
let other = dirs.tests().join("other");
App::test((), |mut app| async move {
let _ = initialize_app(&mut app);
let (_, file_tree_view) =
app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
// Start with `code` as the only root and select its header.
file_tree_view.update(&mut app, |view, ctx| {
view.set_is_active(true, ctx);
view.set_root_directories(vec![code.clone()], ctx);
view.auto_expand_to_most_recent_directory(ctx);
});
file_tree_view.read(&app, |view, _ctx| {
let selected = view.selected_item.as_ref().unwrap();
assert_eq!(selected.root, std_path(&code));
});
// Now cd to a brand-new root. `other` becomes most-recent.
// Selection must move to `other`, not stay on `code`.
file_tree_view.update(&mut app, |view, ctx| {
view.set_root_directories(vec![other.clone(), code.clone()], ctx);
view.auto_expand_to_most_recent_directory(ctx);
});
file_tree_view.read(&app, |view, _ctx| {
let selected = view.selected_item.as_ref().expect("selection set");
assert_eq!(selected.root, std_path(&other));
});
});
},
);
}
#[test]
fn auto_expand_preserves_existing_selection() {
VirtualFS::test(
"file_tree_auto_expand_preserves_selection",
|dirs, mut vfs| {
vfs.mkdir("tree/sub")
.with_files(vec![Stub::FileWithContent("tree/sub/file.txt", "content")]);
let tree = dirs.tests().join("tree");
let sub = tree.join("sub");
App::test((), |mut app| async move {
let _ = initialize_app(&mut app);
let (_, file_tree_view) =
app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
file_tree_view.update(&mut app, |view, ctx| {
view.set_is_active(true, ctx);
view.set_root_directories(vec![tree.clone()], ctx);
});
// Simulate a prior explicit selection (e.g. user focused a
// file in the code editor and `scroll_to_file` selected it).
file_tree_view.update(&mut app, |view, ctx| {
view.toggle_folder_expansion(&std_path(&tree), &std_path(&sub), ctx);
let root_dir = view.root_directories.get(&std_path(&tree)).unwrap();
let (index, _) = root_dir
.items
.iter()
.enumerate()
.find(|(_, item)| item.path() == &std_path(&sub))
.expect("sub directory is flattened");
let id = super::FileTreeIdentifier {
root: std_path(&tree),
index,
};
view.select_id(&id, ctx);
});
// Auto-expand must not override that selection with the root header.
file_tree_view.update(&mut app, |view, ctx| {
view.auto_expand_to_most_recent_directory(ctx);
});
file_tree_view.read(&app, |view, _ctx| {
let selected = view.selected_item.clone().expect("selection set");
let root_dir = view.root_directories.get(&std_path(&tree)).unwrap();
let selected_path = root_dir.items.get(selected.index).unwrap().path();
assert_eq!(selected_path, &std_path(&sub));
});
});
},
);
}
#[test]
fn click_on_file_under_absorbed_descendant_keeps_file_selected() {
// Simulates: user clicks a file in the tree. The code view opens it,
// which causes `DirectoriesChanged` to fire with the file's
// parent/repo added. The resulting `set_root_directories` must NOT
// override the user's file selection with the cwd-follow parent.
VirtualFS::test(
"file_tree_click_file_preserves_selection",
|dirs, mut vfs| {
vfs.mkdir("code/warp-server")
.with_files(vec![Stub::FileWithContent(
"code/warp-server/main.rs",
"fn main() {}\n",
)]);
let code = dirs.tests().join("code");
let warp_server = code.join("warp-server");
let main_rs = warp_server.join("main.rs");
App::test((), |mut app| async move {
let _ = initialize_app(&mut app);
let (_, file_tree_view) =
app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
// Seed with `code` as the only root and expand warp-server so
// main.rs is materialized in the flattened items.
file_tree_view.update(&mut app, |view, ctx| {
view.set_is_active(true, ctx);
view.set_root_directories(vec![code.clone()], ctx);
view.toggle_folder_expansion(&std_path(&code), &std_path(&warp_server), ctx);
});
// Simulate a click on main.rs (select_id is what the click
// action and the active-file scroll both go through).
file_tree_view.update(&mut app, |view, ctx| {
let root_dir = view.root_directories.get(&std_path(&code)).unwrap();
let (index, _) = root_dir
.items
.iter()
.enumerate()
.find(|(_, item)| item.path() == &std_path(&main_rs))
.expect("main.rs materialized");
let id = super::FileTreeIdentifier {
root: std_path(&code),
index,
};
view.select_id(&id, ctx);
});
// Now `DirectoriesChanged` fires as a side effect of the file
// opening in a code view — the working-directories-model adds
// the file's repo/parent (warp-server) to the active set.
file_tree_view.update(&mut app, |view, ctx| {
view.set_root_directories(vec![warp_server.clone(), code.clone()], ctx);
});
file_tree_view.read(&app, |view, _ctx| {
// Selection is still on main.rs, not on warp-server.
let selected = view.selected_item.clone().expect("selection");
let root_dir = view.root_directories.get(&std_path(&code)).unwrap();
let path = root_dir.items.get(selected.index).unwrap().path();
assert_eq!(path, &std_path(&main_rs));
// And we didn't set a pending focus target that could
// later steal focus back to the parent directory.
assert!(view.pending_focus_target.is_none());
});
});
},
);
}
#[test]
fn pending_focus_target_does_not_re_scroll_after_first_apply() {
// After the initial focus-follow scrolls to the cwd, subsequent
// rebuilds (e.g. from repo-metadata updates) must keep the
// selection but NOT re-scroll, so user scrolling is respected.
VirtualFS::test("file_tree_pending_respects_user_scroll", |dirs, mut vfs| {
vfs.mkdir("tree/warp-server")
.with_files(vec![Stub::FileWithContent(
"tree/warp-server/main.rs",
"fn main() {}\n",
)]);
let tree = dirs.tests().join("tree");
let warp_server = tree.join("warp-server");
App::test((), |mut app| async move {
let _ = initialize_app(&mut app);
let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
file_tree_view.update(&mut app, |view, ctx| {
view.set_is_active(true, ctx);
view.set_root_directories(vec![warp_server.clone(), tree.clone()], ctx);
});
// Initial apply should have scrolled once.
file_tree_view.read(&app, |view, _ctx| {
let pending = view.pending_focus_target.as_ref().expect("pending");
assert!(pending.scrolled);
});
// Simulate a later rebuild (e.g. metadata update). Selection
// should still land on warp-server, but `scrolled` must stay
// true (no re-scroll).
file_tree_view.update(&mut app, |view, _ctx| {
view.rebuild_flattened_items();
view.apply_pending_focus_target();
});
file_tree_view.read(&app, |view, _ctx| {
let selected = view.selected_item.clone().expect("selection");
let root_dir = view.root_directories.get(&std_path(&tree)).unwrap();
let path = root_dir.items.get(selected.index).unwrap().path();
assert_eq!(path, &std_path(&warp_server));
let pending = view.pending_focus_target.as_ref().expect("pending");
assert!(pending.scrolled, "scrolled flag stays set after re-apply");
});
});
});
}
#[test]
fn focus_follows_absorbed_descendant_once_its_item_is_materialized() {
VirtualFS::test("file_tree_focus_follow_deferred", |dirs, mut vfs| {
vfs.mkdir("tree/warp-server")
.with_files(vec![Stub::FileWithContent(
"tree/warp-server/main.rs",
"fn main() {}\n",
)]);
let tree = dirs.tests().join("tree");
let warp_server = tree.join("warp-server");
App::test((), |mut app| async move {
let _ = initialize_app(&mut app);
let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
// User cd's into warp-server with ~/tree as the ancestor root.
// The warp-server entry should be materialized by indexing and
// selected as the focus-follow target.
file_tree_view.update(&mut app, |view, ctx| {
view.set_is_active(true, ctx);
view.set_root_directories(vec![warp_server.clone(), tree.clone()], ctx);
});
file_tree_view.read(&app, |view, _ctx| {
// Single displayed root, descendant absorbed.
assert_eq!(view.displayed_directories, vec![std_path(&tree)]);
// Selection landed on warp-server's directory header.
let selected = view.selected_item.clone().expect("selection set");
assert_eq!(selected.root, std_path(&tree));
let root_dir = view.root_directories.get(&std_path(&tree)).unwrap();
let selected_item = root_dir
.items
.get(selected.index)
.expect("selected index in range");
assert_eq!(selected_item.path(), &std_path(&warp_server));
// Pending target is preserved across rebuilds so later
// repo-metadata updates don't override the cwd-follow
// selection. It clears when the user interacts explicitly
// (see pending_focus_target_cleared_on_user_select).
let pending = view
.pending_focus_target
.as_ref()
.expect("pending target preserved");
assert_eq!(pending.root, std_path(&tree));
assert_eq!(pending.path, std_path(&warp_server));
// The initial apply scrolled; later applies must not
// re-scroll so user scrolling is respected.
assert!(pending.scrolled, "initial apply scrolls the tree");
});
// User clicks somewhere else (simulated via select_id). Pending
// target must clear so future rebuilds don't re-steal focus.
file_tree_view.update(&mut app, |view, ctx| {
let root_dir = view.root_directories.get(&std_path(&tree)).unwrap();
let id = super::FileTreeIdentifier {
root: std_path(&tree),
index: 0,
};
// Sanity: the first item is the root header, not warp-server.
assert_ne!(
root_dir.items.first().unwrap().path(),
&std_path(&warp_server)
);
view.select_id(&id, ctx);
});
file_tree_view.read(&app, |view, _ctx| {
assert!(view.pending_focus_target.is_none());
});
});
});
}
#[test]
fn descendant_is_absorbed_into_ancestor() {
VirtualFS::test("file_tree_absorb_descendant", |dirs, mut vfs| {
vfs.mkdir("tree/a")
.with_files(vec![Stub::FileWithContent("tree/a/x.txt", "x")]);
let tree = dirs.tests().join("tree");
let a = tree.join("a");
App::test((), |mut app| async move {
let _ = initialize_app(&mut app);
let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
file_tree_view.update(&mut app, |view, ctx| {
view.set_is_active(true, ctx);
// Input in most-recent-first order: descendant first.
view.set_root_directories(vec![a.clone(), tree.clone()], ctx);
});
file_tree_view.read(&app, |view, _ctx| {
// Only the ancestor survives as a displayed root.
assert_eq!(view.displayed_directories, vec![std_path(&tree)]);
assert!(view.root_directories.contains_key(&std_path(&tree)));
assert!(!view.root_directories.contains_key(&std_path(&a)));
// The absorbed descendant is expanded inside the surviving root.
let root_dir = view.root_directories.get(&std_path(&tree)).unwrap();
assert!(root_dir.expanded_folders.contains(&std_path(&a)));
});
});
});
}
#[test]
fn cd_into_descendant_absorbs_into_existing_ancestor_root() {
VirtualFS::test("file_tree_cd_into_descendant", |dirs, mut vfs| {
vfs.mkdir("tree/a/z")
.with_files(vec![Stub::FileWithContent("tree/a/z/file.txt", "f")]);
let tree = dirs.tests().join("tree");
let z = tree.join("a/z");
App::test((), |mut app| async move {
let _ = initialize_app(&mut app);
let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
// Start with only the ancestor displayed.
file_tree_view.update(&mut app, |view, ctx| {
view.set_is_active(true, ctx);
view.set_root_directories(vec![tree.clone()], ctx);
});
// Simulate cd-ing into ~/tree/a/z by emitting the descendant as the
// most-recent path.
file_tree_view.update(&mut app, |view, ctx| {
view.set_root_directories(vec![z.clone(), tree.clone()], ctx);
});
file_tree_view.read(&app, |view, _ctx| {
// Still a single root, no new top-level entry.
assert_eq!(view.displayed_directories, vec![std_path(&tree)]);
// Ancestor chain is auto-expanded down to the cwd.
let root_dir = view.root_directories.get(&std_path(&tree)).unwrap();
assert!(root_dir
.expanded_folders
.contains(&std_path(&tree.join("a"))));
assert!(root_dir.expanded_folders.contains(&std_path(&z)));
});
});
});
}
#[test]
fn explicit_collapse_blocks_auto_expand_on_absorption() {
VirtualFS::test("file_tree_collapse_blocks_expand", |dirs, mut vfs| {
vfs.mkdir("tree/a/z")
.with_files(vec![Stub::FileWithContent("tree/a/z/file.txt", "f")]);
let tree = dirs.tests().join("tree");
let a = tree.join("a");
let z = a.join("z");
App::test((), |mut app| async move {
let _ = initialize_app(&mut app);
let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
// Start with the ancestor displayed and explicitly collapse `a`.
file_tree_view.update(&mut app, |view, ctx| {
view.set_is_active(true, ctx);
view.set_root_directories(vec![tree.clone()], ctx);
// First expand so the toggle records a collapse.
view.toggle_folder_expansion(&std_path(&tree), &std_path(&a), ctx);
view.toggle_folder_expansion(&std_path(&tree), &std_path(&a), ctx);
});
file_tree_view.read(&app, |view, _ctx| {
assert!(view.is_explicitly_collapsed(&std_path(&tree), &std_path(&a)));
});
// Now cd into ~/tree/a/z. Auto-expansion must not re-open `a`.
file_tree_view.update(&mut app, |view, ctx| {
view.set_root_directories(vec![z.clone(), tree.clone()], ctx);
});
file_tree_view.read(&app, |view, _ctx| {
let root_dir = view.root_directories.get(&std_path(&tree)).unwrap();
assert!(!root_dir.expanded_folders.contains(&std_path(&a)));
assert!(!root_dir.expanded_folders.contains(&std_path(&z)));
assert!(view.is_explicitly_collapsed(&std_path(&tree), &std_path(&a)));
});
});
});
}
#[test]
fn absorption_migrates_expanded_and_explicitly_collapsed_state() {
VirtualFS::test("file_tree_absorb_migrates_state", |dirs, mut vfs| {
vfs.mkdir("tree/a/z")
.with_files(vec![Stub::FileWithContent("tree/a/z/file.txt", "f")]);
let tree = dirs.tests().join("tree");
let a = tree.join("a");
let z = a.join("z");
App::test((), |mut app| async move {
let _ = initialize_app(&mut app);
let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
// Start with `a` as a standalone top-level root and record
// an explicit collapse on `a/z` under that standalone root.
file_tree_view.update(&mut app, |view, ctx| {
view.set_is_active(true, ctx);
view.set_root_directories(vec![a.clone()], ctx);
// Expand then collapse z so the toggle records a collapse on it.
view.toggle_folder_expansion(&std_path(&a), &std_path(&z), ctx);
view.toggle_folder_expansion(&std_path(&a), &std_path(&z), ctx);
});
file_tree_view.read(&app, |view, _ctx| {
assert!(view.is_explicitly_collapsed(&std_path(&a), &std_path(&z)));
});
// Now absorb `a` into `tree` by adding the ancestor.
file_tree_view.update(&mut app, |view, ctx| {
view.set_root_directories(vec![a.clone(), tree.clone()], ctx);
});
file_tree_view.read(&app, |view, _ctx| {
// Standalone absorbed-root entry is gone.
assert!(!view.root_directories.contains_key(&std_path(&a)));
// Its explicit-collapse state moved over to the ancestor.
assert!(view.is_explicitly_collapsed(&std_path(&tree), &std_path(&z)));
});
});
});
}
#[test]
fn absorbed_descendant_is_unregistered_from_lazy_loaded_paths() {
VirtualFS::test("file_tree_absorb_unregisters_lazy", |dirs, mut vfs| {
vfs.mkdir("tree/a")
.with_files(vec![Stub::FileWithContent("tree/a/x.txt", "x")]);
let tree = dirs.tests().join("tree");
let a = tree.join("a");
App::test((), |mut app| async move {
let (_, repository_metadata_model) = initialize_app(&mut app);
let (_, file_tree_view) = app.add_window(WindowStyle::NotStealFocus, FileTreeView::new);
// Initial state: `a` alone is a standalone lazy-loaded root.
file_tree_view.update(&mut app, |view, ctx| {
view.set_is_active(true, ctx);
view.set_root_directories(vec![a.clone()], ctx);
});
file_tree_view.read(&app, |view, _ctx| {
assert!(view.registered_lazy_loaded_paths.contains(&std_path(&a)));
});
repository_metadata_model.read(&app, |model, ctx| {
assert!(model.is_lazy_loaded_path(&std_path(&a), ctx));
});
// Add the ancestor. `a` should be absorbed and its lazy-loaded
// registration should be cleaned up.
file_tree_view.update(&mut app, |view, ctx| {
view.set_root_directories(vec![a.clone(), tree.clone()], ctx);
});
file_tree_view.read(&app, |view, _ctx| {
assert!(!view.registered_lazy_loaded_paths.contains(&std_path(&a)));
assert!(view.registered_lazy_loaded_paths.contains(&std_path(&tree)));
});
repository_metadata_model.read(&app, |model, ctx| {
assert!(!model.is_lazy_loaded_path(&std_path(&a), ctx));
});
});
});
}