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
+29 -12
View File
@@ -440,18 +440,21 @@ impl AssetDatabase {
uid
}
/// Scans the typed asset folders under `assets/` and reconciles the
/// database with what is on disk: existing files keep their uid, new files
/// are [registered](Self::register), and entries whose files no longer exist
/// Scans everything under `assets/` and reconciles the database with what
/// is on disk: existing files keep their uid, new files are
/// [registered](Self::register), and entries whose files no longer exist
/// are dropped. Returns the number of newly registered assets.
///
/// The walk covers the **whole** assets tree, not just the typed folders —
/// the editor's file explorer lets a project organise assets in arbitrary
/// folders, and a scan must never prune them. Files outside a typed folder
/// classify by extension (else [`Other`](AssetKind::Other)), as always.
///
/// Call [`save`](Self::save) afterwards to persist any new uids.
pub fn scan(&mut self) -> usize {
let assets_dir = self.assets_dir();
let mut found: Vec<String> = Vec::new();
for kind in AssetKind::TYPED {
collect_files(&assets_dir.join(kind.folder()), &assets_dir, &mut found);
}
collect_files(&assets_dir, &assets_dir, &mut found);
// Drop entries whose backing file disappeared.
let present: std::collections::HashSet<&String> = found.iter().collect();
@@ -811,12 +814,27 @@ mod tests {
assert_eq!(db.len(), 3);
assert_eq!(db.entries_of_kind(AssetKind::Font).count(), 1);
// The walk covers the whole tree: a custom folder and a loose root
// file both register (kind from extension, else Other) and survive
// subsequent scans.
touch_asset(&root, "props/crate.glb");
touch_asset(&root, "notes.md");
assert_eq!(db.scan(), 2);
let crate_uid = db.uid_of("props/crate.glb").unwrap();
assert_eq!(db.entry(crate_uid).unwrap().kind, AssetKind::Model);
assert_eq!(
db.entry(db.uid_of("notes.md").unwrap()).unwrap().kind,
AssetKind::Other
);
assert_eq!(db.scan(), 0);
assert_eq!(db.uid_of("props/crate.glb"), Some(crate_uid));
// Remove one file and rescan: it is pruned, the rest keep their uids.
let font_uid = db.uid_of("fonts/Inter.ttf").unwrap();
let wall_uid = db.uid_of("textures/wall.png").unwrap();
std::fs::remove_file(root.join(ASSETS_DIR).join("fonts/Inter.ttf")).unwrap();
assert_eq!(db.scan(), 0);
assert_eq!(db.len(), 2);
assert_eq!(db.len(), 4, "wall + cube + crate + notes remain");
assert!(db.entry(font_uid).is_none());
assert_eq!(db.uid_of("textures/wall.png"), Some(wall_uid));
@@ -922,12 +940,11 @@ mod tests {
db.move_asset(uid, "misc/brick.dat").unwrap();
assert_eq!(db.entry(uid).unwrap().kind, AssetKind::Other);
// A rescan does not disturb the moved entry's uid... (misc/ is not a
// typed folder, so the entry survives only because scan never saw it —
// move back first to prove the typed-folder case.)
db.move_asset(uid, "textures/brick.png").unwrap();
// A rescan (which walks the whole tree) does not disturb the moved
// entry's uid, even outside the typed folders.
db.scan();
assert_eq!(db.uid_of("textures/brick.png"), Some(uid));
assert_eq!(db.uid_of("misc/brick.dat"), Some(uid));
db.move_asset(uid, "textures/brick.png").unwrap();
// No-op and error cases.
db.move_asset(uid, "textures/brick.png").unwrap(); // unchanged path: Ok