Initial public release of Warp.
Repo-Sync-Origin: warpdotdev/warp-internal@12af1d983b
This commit is contained in:
@@ -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::*;
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(¤t_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));
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user