refactored file browser tree building + added tests

This commit is contained in:
Dmitry Stepanov
2025-11-27 18:57:11 +03:00
parent 4df5ca3990
commit 101389459d
4 changed files with 308 additions and 202 deletions
+3 -1
View File
@@ -1 +1,3 @@
/test_dir_fetching
/test_dir_fetching
/test_fs_tree_with_root
/test_fs_tree_without_root
+74 -96
View File
@@ -81,6 +81,7 @@ pub fn build_tree_item<P: AsRef<Path>>(
ctx: &mut BuildContext,
root_title: Option<&str>,
) -> Handle<UiNode> {
dbg!(path.as_ref());
let content = GridBuilder::new(
WidgetBuilder::new()
.with_child(if path.as_ref().is_dir() {
@@ -185,35 +186,32 @@ fn disk_letter(components: &[Component]) -> Option<u8> {
None
}
fn sanitize_path(in_path: &Path, root: Option<&PathBuf>) -> PathBuf {
let mut out_path = PathBuf::new();
if let Ok(canonical_in_path) = in_path.canonicalize() {
if let Some(canonical_root) = root.and_then(|r| r.canonicalize().ok()) {
if let Ok(stripped) = canonical_in_path.strip_prefix(canonical_root) {
stripped.clone_into(&mut out_path);
}
} else {
out_path = canonical_in_path;
}
}
// There should be at least one component in the path. If the path is empty, this means that
// it "points" to the current directory.
if out_path.as_os_str().is_empty() {
out_path.push(".");
}
// Relative paths must always start from CurDir component (./), otherwise the root dir will be ignored
// and the tree will be incorrect.
if !out_path.is_absolute() {
out_path = Path::new(".").join(out_path);
}
out_path
struct SanitizedPath {
path: PathBuf,
root_components_to_skip: usize,
}
fn read_dir_entries(dir: &Path, filter: &PathFilter) -> std::io::Result<Vec<PathBuf>> {
impl SanitizedPath {
fn new(path: &Path, root: Option<&PathBuf>) -> std::io::Result<Self> {
let path = path.canonicalize()?;
if let Some(root) = root {
let canonical_root = root.canonicalize()?;
let root_components_to_skip = canonical_root.components().count().saturating_sub(1);
dbg!(&path, &canonical_root, root_components_to_skip);
Ok(Self {
path,
root_components_to_skip,
})
} else {
Ok(Self {
path,
root_components_to_skip: 0,
})
}
}
}
pub(super) fn read_dir_entries(dir: &Path, filter: &PathFilter) -> std::io::Result<Vec<PathBuf>> {
#[allow(clippy::ptr_arg)]
fn sort_dir_entries(a: &PathBuf, b: &PathBuf) -> Ordering {
let a_is_dir = a.is_dir();
@@ -365,24 +363,38 @@ pub fn build_single_folder(
}
}
pub fn tree_path(tree_handle: Handle<UiNode>, ui: &UserInterface) -> Option<PathBuf> {
ui.node(tree_handle).user_data_cloned::<PathBuf>()
}
pub struct FsTree {
pub root_items: Vec<Handle<UiNode>>,
pub path_item: Handle<UiNode>,
}
impl FsTree {
fn empty() -> Self {
Self {
root_items: Default::default(),
path_item: Default::default(),
}
}
/// Builds entire file system tree to given final_path.
pub fn new(
root: Option<&PathBuf>,
final_path: &Path,
path: &Path,
filter: &PathFilter,
menu: RcUiNodeHandle,
root_title: Option<&str>,
ctx: &mut BuildContext,
) -> Self {
let dest_path = sanitize_path(final_path, root);
) -> std::io::Result<Self> {
let SanitizedPath {
path,
root_components_to_skip,
} = SanitizedPath::new(path, root)?;
let dest_path_components = dest_path.components().collect::<Vec<Component>>();
let dest_path_components = path.components().collect::<Vec<Component>>();
let RootsCollection {
items: mut root_items,
@@ -397,6 +409,10 @@ impl FsTree {
// Concat parts of path one by one.
full_path = full_path.join(component.as_os_str());
if i < root_components_to_skip {
continue;
}
let next_component = dest_path_components.get(i + 1);
if let Some(next_component) = next_component {
@@ -410,12 +426,12 @@ impl FsTree {
let next = next_component.map(|p| full_path.join(p));
let mut new_parent = parent;
if let Ok(entries) = read_dir_entries(&full_path, filter) {
for path in entries {
let is_part_of_final_path = next.as_ref().is_some_and(|next| *next == path);
if let Ok(dir_entries) = read_dir_entries(&full_path, filter) {
for dir_path in dir_entries {
let is_part_of_final_path = next.as_ref().is_some_and(|next| *next == dir_path);
let item = build_tree_item(
&path,
&dir_path,
&full_path,
menu.clone(),
is_part_of_final_path,
@@ -433,7 +449,7 @@ impl FsTree {
new_parent = item;
}
if path == dest_path {
if dir_path == path {
path_item = item;
}
}
@@ -441,68 +457,30 @@ impl FsTree {
parent = new_parent;
}
Self {
Ok(Self {
root_items,
path_item,
})
}
pub fn new_or_empty(
root: Option<&PathBuf>,
path: &Path,
filter: &PathFilter,
menu: RcUiNodeHandle,
root_title: Option<&str>,
ctx: &mut BuildContext,
) -> Self {
match Self::new(root, path, filter, menu, root_title, ctx) {
Ok(fs_tree) => fs_tree,
Err(err) => {
err!(
"Unable to rebuild FS tree for {} path! Reason: {}",
path.display(),
err
);
Self::empty()
}
}
}
}
#[cfg(test)]
mod tests {
use crate::file_browser::{fs_tree::read_dir_entries, PathFilter};
use std::{
fs::File,
path::{Path, PathBuf},
};
fn create_dir(path: impl AsRef<Path>) {
std::fs::create_dir_all(path).unwrap()
}
fn write_empty_file(path: impl AsRef<Path>) {
File::create(path).unwrap();
}
fn create_dirs(paths: &[PathBuf]) {
for path in paths {
create_dir(path)
}
}
fn write_empty_files(paths: &[PathBuf]) {
for path in paths {
write_empty_file(path)
}
}
fn clean_or_create(path: impl AsRef<Path>) {
if path.as_ref().exists() {
std::fs::remove_dir_all(path).unwrap();
} else {
create_dir(path);
}
}
#[test]
fn test_dir_fetching() {
let path = Path::new("./test_dir_fetching");
clean_or_create(path);
let folders = [path.join("dir1"), path.join("dir2"), path.join("dir3")];
let files = [path.join("file1"), path.join("file2"), path.join("file3")];
create_dirs(&folders);
write_empty_files(&files);
let entries = read_dir_entries(path, &PathFilter::AllPass).unwrap();
assert_eq!(entries[0..3], folders);
assert_eq!(entries[3..6], files);
let files_copy = files.clone();
let folders_copy = folders.clone();
let entries = read_dir_entries(
path,
&PathFilter::new(move |path| path != files_copy[0] && path != folders_copy[2]),
)
.unwrap();
assert_eq!(entries[0..2], folders[0..2]);
assert_eq!(entries[2..4], files[1..3]);
}
}
+42 -105
View File
@@ -59,6 +59,9 @@ mod fs_tree;
mod menu;
mod selector;
#[cfg(test)]
mod test;
pub use filter::*;
pub use selector::*;
@@ -175,7 +178,7 @@ impl FileBrowser {
}
fn rebuild_fs_tree(&mut self, ui: &mut UserInterface) {
let fs_tree = fs_tree::FsTree::new(
let fs_tree = fs_tree::FsTree::new_or_empty(
self.root.as_ref(),
&self.path,
&self.filter,
@@ -362,19 +365,16 @@ impl Control for FileBrowser {
} else if let Some(TreeMessage::Expand { expand, .. }) = message.data::<TreeMessage>() {
if *expand {
// Look into internals of directory and build tree items.
let parent_path = ui
.node(message.destination())
.user_data_cloned::<PathBuf>()
.unwrap()
.clone();
fs_tree::build_single_folder(
&parent_path,
message.destination(),
self.item_context_menu.clone(),
self.root_title.as_deref(),
&self.filter,
ui,
)
if let Some(parent_path) = fs_tree::tree_path(message.destination(), ui) {
fs_tree::build_single_folder(
&parent_path,
message.destination(),
self.item_context_menu.clone(),
self.root_title.as_deref(),
&self.filter,
ui,
)
}
} else {
// Nuke everything in collapsed item. This also will free some resources
// and will speed up layout pass.
@@ -388,60 +388,48 @@ impl Control for FileBrowser {
}
} else if let Some(WidgetMessage::Drop(dropped)) = message.data() {
if !message.handled() {
if let Some(path) = ui.node(message.destination()).user_data_cloned::<PathBuf>() {
if let Some(path) = fs_tree::tree_path(message.destination(), ui) {
ui.post(
self.handle,
FileBrowserMessage::Drop {
dropped: *dropped,
path_item: message.destination(),
path: path.clone(),
dropped_path: ui
.node(*dropped)
.user_data_cloned::<PathBuf>()
.unwrap_or_default(),
dropped_path: fs_tree::tree_path(*dropped, ui).unwrap_or_default(),
},
);
message.set_handled(true);
}
}
} else if let Some(TreeRootMessage::Select(selection)) = message.data::<TreeRootMessage>() {
if message.destination() == self.tree_root
&& message.direction() == MessageDirection::FromWidget
{
if let Some(&first_selected) = selection.first() {
if let Some(first_selected_ref) = ui.try_get_node(first_selected) {
let mut path = first_selected_ref
.user_data_cloned::<PathBuf>()
.unwrap()
.clone();
if let FileBrowserMode::Save { .. } = self.mode {
if path.is_file() {
ui.send(
self.file_name,
TextMessage::Text(
path.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default(),
),
);
} else {
path = path.join(&self.file_name_value);
}
}
if self.path != path {
self.path.clone_from(&path);
} else if let Some(TreeRootMessage::Select(selection)) = message.data_from(self.tree_root) {
if let Some(&first_selected) = selection.first() {
if let Some(mut path) = fs_tree::tree_path(first_selected, ui) {
if let FileBrowserMode::Save { .. } = self.mode {
if path.is_file() {
ui.send(
self.path_text,
TextMessage::Text(path.to_string_lossy().to_string()),
self.file_name,
TextMessage::Text(
path.file_name()
.map(|f| f.to_string_lossy().to_string())
.unwrap_or_default(),
),
);
// Do response.
ui.post(self.handle, FileBrowserMessage::Path(path));
} else {
path = path.join(&self.file_name_value);
}
}
if self.path != path {
self.path.clone_from(&path);
ui.send(
self.path_text,
TextMessage::Text(path.to_string_lossy().to_string()),
);
// Do response.
ui.post(self.handle, FileBrowserMessage::Path(path));
}
}
}
} else if let Some(ButtonMessage::Click) = message.data() {
@@ -579,7 +567,7 @@ impl FileBrowserBuilder {
let fs_tree::FsTree {
root_items: items, ..
} = fs_tree::FsTree::new(
} = fs_tree::FsTree::new_or_empty(
self.root.as_ref(),
self.path.as_path(),
&self.filter,
@@ -791,54 +779,3 @@ fn setup_filebrowser_fs_watcher(
Err(_) => None,
}
}
#[cfg(test)]
mod test {
use crate::file_browser::{fs_tree, FileBrowserBuilder};
use crate::test::test_widget_deletion;
use crate::{
core::pool::Handle, tree::TreeRootBuilder, widget::WidgetBuilder, RcUiNodeHandle,
UserInterface,
};
use fyrox_core::algebra::Vector2;
use fyrox_core::parking_lot::Mutex;
use std::path::PathBuf;
use std::sync::Arc;
#[test]
fn test_deletion() {
test_widget_deletion(|ctx| FileBrowserBuilder::new(WidgetBuilder::new()).build(ctx));
}
#[test]
fn test_find_tree() {
let mut ui = UserInterface::new(Vector2::new(100.0, 100.0));
let root = TreeRootBuilder::new(
WidgetBuilder::new().with_user_data(Arc::new(Mutex::new(PathBuf::from("test")))),
)
.build(&mut ui.build_ctx());
let path = fs_tree::build_tree(
root,
true,
"./test/path1",
"./test",
RcUiNodeHandle::new(Handle::new(0, 1), ui.sender()),
None,
&mut ui,
);
while ui.poll_message().is_some() {}
// This passes.
assert_eq!(fs_tree::find_tree_item(root, &"./test/path1", &ui), path);
// This expected to fail
// https://github.com/rust-lang/rust/issues/31374
assert_eq!(
fs_tree::find_tree_item(root, &"test/path1", &ui),
Handle::NONE
);
}
}
+189
View File
@@ -0,0 +1,189 @@
// Copyright (c) 2019-present Dmitry Stepanov and Fyrox Engine contributors.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
use crate::test::UserInterfaceTestingExtension;
use crate::{
core::{algebra::Vector2, parking_lot::Mutex, pool::Handle},
file_browser::{fs_tree, fs_tree::read_dir_entries, FileBrowserBuilder, PathFilter},
test::test_widget_deletion,
tree::TreeRootBuilder,
widget::WidgetBuilder,
RcUiNodeHandle, UiNode, UserInterface,
};
use fyrox_graph::SceneGraph;
use std::{
fs::File,
path::{Path, PathBuf},
sync::Arc,
};
fn create_dir(path: impl AsRef<Path>) {
std::fs::create_dir_all(path).unwrap()
}
fn write_empty_file(path: impl AsRef<Path>) {
if let Some(parent) = path.as_ref().parent() {
if !parent.exists() {
create_dir(parent)
}
}
File::create(path).unwrap();
}
fn create_dirs(paths: &[PathBuf]) {
for path in paths {
create_dir(path)
}
}
fn write_empty_files(paths: &[PathBuf]) {
for path in paths {
write_empty_file(path)
}
}
fn clean_or_create(path: impl AsRef<Path>) {
if path.as_ref().exists() {
std::fs::remove_dir_all(path).unwrap();
} else {
create_dir(path);
}
}
fn find_by_path(path: impl AsRef<Path>, ui: &UserInterface) -> Handle<UiNode> {
ui.find_from_root(&mut |n| {
n.user_data_cloned::<PathBuf>()
.is_some_and(|p| p == path.as_ref())
})
.map(|(h, _)| h)
.unwrap_or_default()
}
#[test]
fn test_deletion() {
test_widget_deletion(|ctx| FileBrowserBuilder::new(WidgetBuilder::new()).build(ctx));
}
#[test]
fn test_find_tree() {
let mut ui = UserInterface::new(Vector2::new(100.0, 100.0));
let root = TreeRootBuilder::new(
WidgetBuilder::new().with_user_data(Arc::new(Mutex::new(PathBuf::from("test")))),
)
.build(&mut ui.build_ctx());
let path = fs_tree::build_tree(
root,
true,
"./test/path1",
"./test",
RcUiNodeHandle::new(Handle::new(0, 1), ui.sender()),
None,
&mut ui,
);
while ui.poll_message().is_some() {}
// This passes.
assert_eq!(fs_tree::find_tree_item(root, &"./test/path1", &ui), path);
// This expected to fail
// https://github.com/rust-lang/rust/issues/31374
assert_eq!(
fs_tree::find_tree_item(root, &"test/path1", &ui),
Handle::NONE
);
}
#[test]
fn test_dir_fetching() {
let path = Path::new("./test_dir_fetching");
clean_or_create(path);
let folders = [path.join("dir1"), path.join("dir2"), path.join("dir3")];
let files = [path.join("file1"), path.join("file2"), path.join("file3")];
create_dirs(&folders);
write_empty_files(&files);
let entries = read_dir_entries(path, &PathFilter::AllPass).unwrap();
assert_eq!(entries[0..3], folders);
assert_eq!(entries[3..6], files);
let files_copy = files.clone();
let folders_copy = folders.clone();
let entries = read_dir_entries(
path,
&PathFilter::new(move |path| path != files_copy[0] && path != folders_copy[2]),
)
.unwrap();
assert_eq!(entries[0..2], folders[0..2]);
assert_eq!(entries[2..4], files[1..3]);
}
#[test]
fn test_fs_tree_with_root() {
let root = PathBuf::from("./test_fs_tree_with_root");
clean_or_create(&root);
let paths = [
root.join("file1"),
root.join("file2"),
root.join("file3"),
root.join("subdir").join("file1"),
root.join("subdir").join("file2"),
root.join("subdir").join("file3"),
];
write_empty_files(&paths);
let screen_size = Vector2::repeat(1000.0);
let mut ui = UserInterface::new(screen_size);
let ctx = &mut ui.build_ctx();
FileBrowserBuilder::new(WidgetBuilder::new())
.with_root(root)
.with_path(paths.last().as_deref().unwrap())
.build(ctx);
ui.poll_all_messages();
for path in paths {
let path = path.canonicalize().unwrap();
assert!(find_by_path(path, &ui).is_some());
}
}
#[test]
fn test_fs_tree_without_root() {
let root = PathBuf::from("./test_fs_tree_without_root");
clean_or_create(&root);
let paths = [
root.join("file1"),
root.join("file2"),
root.join("file3"),
root.join("subdir").join("file1"),
root.join("subdir").join("file2"),
root.join("subdir").join("file3"),
];
write_empty_files(&paths);
let screen_size = Vector2::repeat(1000.0);
let mut ui = UserInterface::new(screen_size);
let ctx = &mut ui.build_ctx();
FileBrowserBuilder::new(WidgetBuilder::new())
.with_path(paths.last().as_deref().unwrap())
.build(ctx);
ui.poll_all_messages();
for path in paths {
let path = path.canonicalize().unwrap();
assert!(find_by_path(path, &ui).is_some());
}
}