Editor: Unity-style file explorer in the Project panel

The last item of the Stage-10 editor-UX batch. The Project panel's
fixed typed-folder listing becomes a real file explorer over assets/:

- breadcrumbs + double-click folder navigation, " New Folder", inline
  rename rows, context menus (Open / Rename / Delete), drag a row onto
  a folder (or "..") to move it, drag files in from the OS to import
  into the current folder, double-click to open (scripts via the
  external-editor flow, others via xdg-open).
- All behavior lives egui-free in editor/src/explorer.rs (listing,
  breadcrumbs, name validation/uniquing, create/rename/move/delete/
  import) and is unit-tested; the shell only renders it. Renames and
  moves ride the uid-preserving AssetDatabase ops so saved AssetRefs
  keep resolving; unregistered files fall back to fs::rename. Folders
  delete only when empty — no recursive asset deletion.
- Engine: AssetDatabase::scan now walks the WHOLE assets/ tree instead
  of just the typed folders, so assets organised into custom folders
  register and survive rescans (covered by updated unit tests).

File operations act immediately and bypass the undo stack, like the
hierarchy's structural edits. GUI piece — needs an eye-check before
promotion to main.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Homer
2026-07-10 21:01:05 +02:00
parent 3c59faa506
commit bad780de9d
4 changed files with 908 additions and 73 deletions
+524
View File
@@ -0,0 +1,524 @@
//! 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<RenameEdit>,
/// The in-progress "New Folder" name, `Some` while the inline row shows.
pub new_folder: Option<String>,
/// 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<String>) {
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<AssetUid>,
/// The registered kind, when the file is registered.
pub kind: Option<AssetKind>,
}
/// 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<Entry> {
let dir = abs_of(db, cwd);
let Ok(read) = std::fs::read_dir(&dir) else {
return Vec::new();
};
let mut folders: Vec<Entry> = Vec::new();
let mut files: Vec<Entry> = 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<String> {
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<String, AssetDbError> {
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<String, AssetDbError> {
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();
}
}