//! The Project panel's file explorer — state and file/database operations. //! //! Stage-10 editor-UX: a Unity-style explorer over the project's `assets/` //! tree. This module holds everything that does **not** touch egui — the //! navigation state, directory listing, and the create/rename/move/delete/ //! import operations — so the whole behavior layer is unit-testable and the //! shell only renders it (`ShellTabViewer::assets_explorer`). //! //! Every operation goes through the [`AssetDatabase`] file ops //! (`move_asset`/`move_folder`/`delete_asset`) whenever the touched file is //! registered, so an asset keeps its [`AssetUid`] — and every saved //! `AssetRef` keeps resolving — across any reorganisation. Files the //! database does not know (licenses, notes, …) fall back to plain //! filesystem operations. use std::path::{Path, PathBuf}; use oxide_engine::asset::{AssetDatabase, AssetDbError, AssetKind, AssetUid}; /// The explorer's persistent UI state (lives on the `Shell`, survives frames). #[derive(Default)] pub struct ExplorerState { /// The folder being viewed, relative to `assets/` (`""` = the root). pub cwd: String, /// An in-progress rename, if any. pub rename: Option, /// The in-progress "New Folder" name, `Some` while the inline row shows. pub new_folder: Option, /// One-shot: the next inline text field rendered requests focus (set when /// a rename / new-folder edit starts, taken by the first frame). pub focus_field: bool, } impl ExplorerState { /// Navigates to `cwd`, dropping any in-progress inline edits. pub fn navigate(&mut self, cwd: impl Into) { self.cwd = cwd.into(); self.rename = None; self.new_folder = None; } } /// An in-progress rename of one entry: what is being renamed + the buffer. pub struct RenameEdit { /// The entry's current assets-relative path. pub rel: String, /// Whether it is a folder. pub is_dir: bool, /// The name being typed. pub buf: String, } /// One row of the explorer listing. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Entry { /// The leaf name shown in the panel. pub name: String, /// Assets-relative path (forward slashes). pub rel: String, /// Whether this is a folder. pub is_dir: bool, /// The database uid, when the file is registered. pub uid: Option, /// The registered kind, when the file is registered. pub kind: Option, } /// Lists the folder `cwd` (relative to `assets/`): folders first, then files, /// each group sorted by name. Files are annotated with their database /// uid/kind when registered. A missing folder yields an empty list (the /// assets root may not exist yet in a fresh project). pub fn list_dir(db: &AssetDatabase, cwd: &str) -> Vec { let dir = abs_of(db, cwd); let Ok(read) = std::fs::read_dir(&dir) else { return Vec::new(); }; let mut folders: Vec = Vec::new(); let mut files: Vec = Vec::new(); for item in read.flatten() { let name = item.file_name().to_string_lossy().into_owned(); let rel = join_rel(cwd, &name); if item.path().is_dir() { folders.push(Entry { name, rel, is_dir: true, uid: None, kind: None, }); } else { let uid = db.uid_of(&rel); let kind = uid.and_then(|u| db.entry(u)).map(|e| e.kind); files.push(Entry { name, rel, is_dir: false, uid, kind, }); } } folders.sort_by(|a, b| a.name.cmp(&b.name)); files.sort_by(|a, b| a.name.cmp(&b.name)); folders.extend(files); folders } /// The breadcrumb trail for `cwd`: `(label, cwd-to-navigate-to)` pairs, /// starting at the assets root. `"textures/env"` yields /// `[("assets",""), ("textures","textures"), ("env","textures/env")]`. pub fn breadcrumbs(cwd: &str) -> Vec<(String, String)> { let mut crumbs = vec![("assets".to_owned(), String::new())]; let mut path = String::new(); for seg in cwd.split('/').filter(|s| !s.is_empty()) { path = join_rel(&path, seg); crumbs.push((seg.to_owned(), path.clone())); } crumbs } /// Joins a folder path and a leaf name into an assets-relative path. pub fn join_rel(dir: &str, name: &str) -> String { if dir.is_empty() { name.to_owned() } else { format!("{dir}/{name}") } } /// The parent folder of an assets-relative path (`""` at the top). pub fn parent_of(rel: &str) -> String { rel.rsplit_once('/') .map(|(p, _)| p.to_owned()) .unwrap_or_default() } /// Whether `name` is usable as a single new file/folder name: non-empty and /// free of path separators / traversal. pub fn valid_name(name: &str) -> bool { !name.is_empty() && name != "." && name != ".." && !name.contains('/') && !name.contains('\\') } /// A name that does not exist in `dir` yet, derived from `wanted` by /// suffixing `_2`, `_3`, … before the extension (`wall.png` → `wall_2.png`). pub fn unique_name(dir: &Path, wanted: &str) -> String { if !dir.join(wanted).exists() { return wanted.to_owned(); } let (stem, ext) = match wanted.rsplit_once('.') { // A leading dot (".gitignore") is a hidden name, not an extension. Some((s, e)) if !s.is_empty() => (s, Some(e)), _ => (wanted, None), }; let mut n = 2; loop { let candidate = match ext { Some(ext) => format!("{stem}_{n}.{ext}"), None => format!("{stem}_{n}"), }; if !dir.join(&candidate).exists() { return candidate; } n += 1; } } /// Creates a new folder in `cwd` named `wanted` (unique-ified), returning its /// assets-relative path. pub fn create_folder(db: &AssetDatabase, cwd: &str, wanted: &str) -> std::io::Result { if !valid_name(wanted) { return Err(std::io::Error::new( std::io::ErrorKind::InvalidInput, format!("invalid folder name: {wanted:?}"), )); } let dir = abs_of(db, cwd); std::fs::create_dir_all(&dir)?; let name = unique_name(&dir, wanted); std::fs::create_dir(dir.join(&name))?; Ok(join_rel(cwd, &name)) } /// Renames the entry at `rel` to `new_name` (same folder), returning the new /// relative path. Registered files keep their uid via /// [`AssetDatabase::move_asset`]; folders move every registered entry under /// them via [`AssetDatabase::move_folder`]; unregistered files fall back to a /// plain `fs::rename` (refusing to overwrite). pub fn rename_entry( db: &mut AssetDatabase, rel: &str, is_dir: bool, new_name: &str, ) -> Result { if !valid_name(new_name) { return Err(AssetDbError::InvalidPath(new_name.to_owned())); } let new_rel = join_rel(&parent_of(rel), new_name); if new_rel == rel { return Ok(new_rel); } move_to(db, rel, is_dir, &new_rel)?; Ok(new_rel) } /// Moves the entry at `rel` into the folder `dest_dir`, returning the new /// relative path. Same uid-preserving rules as [`rename_entry`]. pub fn move_entry( db: &mut AssetDatabase, rel: &str, is_dir: bool, dest_dir: &str, ) -> Result { let name = rel.rsplit('/').next().unwrap_or(rel); let new_rel = join_rel(dest_dir, name); if new_rel == rel { return Ok(new_rel); } move_to(db, rel, is_dir, &new_rel)?; Ok(new_rel) } /// Deletes the entry: registered files through the database (entry dropped, /// uid retired), unregistered files from disk, and folders **only when /// empty** — recursive delete of assets is deliberately not offered. pub fn delete_entry(db: &mut AssetDatabase, entry: &Entry) -> Result<(), AssetDbError> { if entry.is_dir { let dir = abs_of(db, &entry.rel); if std::fs::read_dir(&dir)?.next().is_some() { // (`ErrorKind::DirectoryNotEmpty` needs Rust 1.83; the workspace // MSRV is older, so this stays a generic I/O error.) return Err(AssetDbError::Io(std::io::Error::other(format!( "folder not empty: {} (delete its contents first)", entry.rel )))); } std::fs::remove_dir(&dir)?; return Ok(()); } match entry.uid { Some(uid) => delete_and_save(db, uid), None => Ok(std::fs::remove_file(abs_of(db, &entry.rel))?), } } /// Imports files dropped from the OS into `cwd`: each is copied in under a /// collision-free name and registered (kind from the folder, else extension). /// Directories and unreadable sources are skipped with a log line. Returns /// how many files were imported. pub fn import_files(db: &mut AssetDatabase, cwd: &str, sources: &[PathBuf]) -> usize { let dir = abs_of(db, cwd); if std::fs::create_dir_all(&dir).is_err() { return 0; } let mut imported = 0; for src in sources { if src.is_dir() { log::warn!("skipping folder drop {} (import files)", src.display()); continue; } let Some(file_name) = src.file_name().map(|n| n.to_string_lossy().into_owned()) else { continue; }; let name = unique_name(&dir, &file_name); match std::fs::copy(src, dir.join(&name)) { Ok(_) => { let rel = join_rel(cwd, &name); db.register(&rel); log::info!("imported {rel}"); imported += 1; } Err(err) => log::warn!("could not import {}: {err}", src.display()), } } if imported > 0 { if let Err(err) = db.save() { log::warn!("could not write asset manifest: {err}"); } } imported } // --- internals ----------------------------------------------------------- /// The absolute path of an assets-relative path under the database's root. fn abs_of(db: &AssetDatabase, rel: &str) -> PathBuf { let assets = db.assets_dir(); if rel.is_empty() { assets } else { assets.join(rel.replace('/', std::path::MAIN_SEPARATOR_STR)) } } /// Routes a rename/move to the right primitive: `move_folder` for folders, /// `move_asset` for registered files, `fs::rename` (no overwrite) for /// unregistered ones. Saves the manifest after a database change. fn move_to( db: &mut AssetDatabase, rel: &str, is_dir: bool, new_rel: &str, ) -> Result<(), AssetDbError> { if is_dir { db.move_folder(rel, new_rel)?; save_manifest(db); return Ok(()); } match db.uid_of(rel) { Some(uid) => { db.move_asset(uid, new_rel)?; save_manifest(db); Ok(()) } None => { let to = abs_of(db, new_rel); if to.exists() { return Err(AssetDbError::DestinationExists(new_rel.to_owned())); } if let Some(parent) = to.parent() { std::fs::create_dir_all(parent)?; } Ok(std::fs::rename(abs_of(db, rel), to)?) } } } /// Deletes a registered asset and persists the manifest. fn delete_and_save(db: &mut AssetDatabase, uid: AssetUid) -> Result<(), AssetDbError> { db.delete_asset(uid)?; save_manifest(db); Ok(()) } /// Best-effort manifest save after a mutation (failure → Console, not fatal). fn save_manifest(db: &AssetDatabase) { if let Err(err) = db.save() { log::warn!("could not write asset manifest: {err}"); } } #[cfg(test)] mod tests { use super::*; use std::sync::atomic::{AtomicU32, Ordering}; static COUNTER: AtomicU32 = AtomicU32::new(0); /// A fresh project root with an `assets/` tree and an open database. fn scratch_db(files: &[&str]) -> (PathBuf, AssetDatabase) { let root = std::env::temp_dir().join(format!( "oxide_explorer_test_{}_{}", std::process::id(), COUNTER.fetch_add(1, Ordering::SeqCst), )); let assets = root.join("assets"); std::fs::create_dir_all(&assets).unwrap(); for rel in files { let full = assets.join(rel); std::fs::create_dir_all(full.parent().unwrap()).unwrap(); std::fs::write(full, b"x").unwrap(); } let mut db = AssetDatabase::new(&root); db.scan(); (root, db) } #[test] fn breadcrumbs_and_path_helpers() { assert_eq!(breadcrumbs(""), vec![("assets".to_owned(), String::new())]); assert_eq!( breadcrumbs("textures/env"), vec![ ("assets".to_owned(), String::new()), ("textures".to_owned(), "textures".to_owned()), ("env".to_owned(), "textures/env".to_owned()), ] ); assert_eq!(join_rel("", "a"), "a"); assert_eq!(join_rel("a/b", "c"), "a/b/c"); assert_eq!(parent_of("a/b/c"), "a/b"); assert_eq!(parent_of("a"), ""); assert!(valid_name("wall.png")); assert!(!valid_name("")); assert!(!valid_name("a/b")); assert!(!valid_name("..")); } #[test] fn list_dir_sorts_folders_first_and_annotates_registered_files() { let (root, db) = scratch_db(&["textures/wall.png", "textures/env/sky.png", "notes.md"]); let top = list_dir(&db, ""); let names: Vec<&str> = top.iter().map(|e| e.name.as_str()).collect(); assert_eq!(names, ["textures", "notes.md"]); assert!(top[0].is_dir && top[0].uid.is_none()); assert_eq!(top[1].kind, Some(AssetKind::Other)); let textures = list_dir(&db, "textures"); let names: Vec<&str> = textures.iter().map(|e| e.name.as_str()).collect(); assert_eq!(names, ["env", "wall.png"]); assert_eq!(textures[1].kind, Some(AssetKind::Texture)); assert_eq!(textures[1].uid, db.uid_of("textures/wall.png")); // A folder that does not exist lists as empty, not an error. assert!(list_dir(&db, "nope").is_empty()); std::fs::remove_dir_all(root).ok(); } #[test] fn unique_name_suffixes_before_the_extension() { let (root, db) = scratch_db(&["textures/wall.png"]); let dir = db.assets_dir().join("textures"); assert_eq!(unique_name(&dir, "new.png"), "new.png"); assert_eq!(unique_name(&dir, "wall.png"), "wall_2.png"); std::fs::write(dir.join("wall_2.png"), b"x").unwrap(); assert_eq!(unique_name(&dir, "wall.png"), "wall_3.png"); std::fs::remove_dir_all(root).ok(); } #[test] fn create_folder_is_unique_and_validated() { let (root, db) = scratch_db(&[]); assert_eq!(create_folder(&db, "", "props").unwrap(), "props"); assert_eq!(create_folder(&db, "", "props").unwrap(), "props_2"); assert_eq!(create_folder(&db, "props", "env").unwrap(), "props/env"); assert!(create_folder(&db, "", "a/b").is_err()); std::fs::remove_dir_all(root).ok(); } #[test] fn rename_and_move_preserve_uids() { let (root, mut db) = scratch_db(&["textures/wall.png", "textures/env/sky.png"]); let wall = db.uid_of("textures/wall.png").unwrap(); let sky = db.uid_of("textures/env/sky.png").unwrap(); // Rename a file in place. let new_rel = rename_entry(&mut db, "textures/wall.png", false, "brick.png").unwrap(); assert_eq!(new_rel, "textures/brick.png"); assert_eq!(db.relative_path(wall), Some("textures/brick.png")); // Move it into a sibling folder. let new_rel = move_entry(&mut db, "textures/brick.png", false, "textures/env").unwrap(); assert_eq!(new_rel, "textures/env/brick.png"); assert_eq!(db.relative_path(wall), Some("textures/env/brick.png")); // Rename the folder: both entries follow, uids intact. let new_rel = rename_entry(&mut db, "textures/env", true, "world").unwrap(); assert_eq!(new_rel, "textures/world"); assert_eq!(db.relative_path(sky), Some("textures/world/sky.png")); assert_eq!(db.relative_path(wall), Some("textures/world/brick.png")); // Invalid target name is refused. assert!(rename_entry(&mut db, "textures/world", true, "a/b").is_err()); // The manifest was persisted along the way. let reloaded = AssetDatabase::open(&root); assert_eq!(reloaded.relative_path(sky), Some("textures/world/sky.png")); std::fs::remove_dir_all(root).ok(); } #[test] fn unregistered_files_rename_through_the_filesystem() { let (root, mut db) = scratch_db(&[]); // A file the database does not track (e.g. a license dropped next to // a font). Note scratch_db scans, so create it *after*. let assets = db.assets_dir(); std::fs::write(assets.join("OFL.txt"), b"x").unwrap(); assert!(db.uid_of("OFL.txt").is_none()); let new_rel = rename_entry(&mut db, "OFL.txt", false, "LICENSE.txt").unwrap(); assert_eq!(new_rel, "LICENSE.txt"); assert!(assets.join("LICENSE.txt").is_file()); assert!(!assets.join("OFL.txt").exists()); std::fs::remove_dir_all(root).ok(); } #[test] fn delete_rules_files_yes_folders_only_when_empty() { let (root, mut db) = scratch_db(&["textures/wall.png"]); let wall_entry = list_dir(&db, "textures") .into_iter() .find(|e| e.name == "wall.png") .unwrap(); let folder_entry = list_dir(&db, "") .into_iter() .find(|e| e.name == "textures") .unwrap(); // Non-empty folder refused; file deletes (entry + disk); empty folder ok. assert!(delete_entry(&mut db, &folder_entry).is_err()); delete_entry(&mut db, &wall_entry).unwrap(); assert!(db.uid_of("textures/wall.png").is_none()); delete_entry(&mut db, &folder_entry).unwrap(); assert!(list_dir(&db, "").is_empty()); std::fs::remove_dir_all(root).ok(); } #[test] fn import_copies_registers_and_dodges_collisions() { let (root, mut db) = scratch_db(&["textures/wall.png"]); // Two outside files, one colliding with an existing asset name. let outside = root.join("outside"); std::fs::create_dir_all(&outside).unwrap(); std::fs::write(outside.join("wall.png"), b"new").unwrap(); std::fs::write(outside.join("tree.glb"), b"tree").unwrap(); let n = import_files( &mut db, "textures", &[outside.join("wall.png"), outside.join("tree.glb")], ); assert_eq!(n, 2); assert!(db.uid_of("textures/wall_2.png").is_some()); // Kind follows the *folder* it was dropped into. let tree = db.uid_of("textures/tree.glb").unwrap(); assert_eq!(db.entry(tree).unwrap().kind, AssetKind::Texture); std::fs::remove_dir_all(root).ok(); } }