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 48003ffea4
commit c47efa876f
6 changed files with 941 additions and 81 deletions
+6
View File
@@ -676,6 +676,12 @@ console)**. Each is GUI → `dev` + eye-check.
menus, breadcrumb navigation. The `AssetDatabase` already tracks arbitrary menus, breadcrumb navigation. The `AssetDatabase` already tracks arbitrary
relative paths, so this is a UI/interaction layer over it (re-classify by relative paths, so this is a UI/interaction layer over it (re-classify by
folder still applies; loose files fall back to extension). folder still applies; loose files fall back to extension).
**🚧 On `dev` (2026-07-10), awaiting eye-check**: breadcrumbs + folder
navigation, New Folder, rename/delete context menus (folders delete only when
empty), drag-to-move rows, OS drag-in import, double-click-to-open. Behavior
layer is unit-tested (`editor/src/explorer.rs`); renames/moves ride the new
uid-preserving `AssetDatabase` file ops, and `scan` now walks the whole
`assets/` tree so custom folders survive rescans.
- **Proper file/path opener for New/Open Project.** Today both take a raw typed - **Proper file/path opener for New/Open Project.** Today both take a raw typed
path, which the maintainer confirms "is very hard to use". Wanted: a native path, which the maintainer confirms "is very hard to use". Wanted: a native
file/folder picker (a `rfd`-style dialog, working on Wayland + X11) and/or a file/folder picker (a `rfd`-style dialog, working on Wayland + X11) and/or a
+27 -8
View File
@@ -138,7 +138,7 @@ gives an absolute path the server loads and deduplicates.
use oxide_engine::asset::{AssetDatabase, AssetServer, GltfModel}; use oxide_engine::asset::{AssetDatabase, AssetServer, GltfModel};
let mut db = AssetDatabase::open(project_root); // reads assets.manifest if present let mut db = AssetDatabase::open(project_root); // reads assets.manifest if present
db.scan(); // discover files in fonts/ models/ … db.scan(); // walk assets/ — register new files, prune missing
db.save().unwrap(); // persist any newly-assigned uids db.save().unwrap(); // persist any newly-assigned uids
let uid = db.uid_of("models/cube.glb").unwrap(); let uid = db.uid_of("models/cube.glb").unwrap();
@@ -224,13 +224,32 @@ type) is recognised the same way.
`EditorState` holds an `asset_db` whenever a project is open: the editor opens `EditorState` holds an `asset_db` whenever a project is open: the editor opens
and scans it on project open/create and rescans when the file watcher reports and scans it on project open/create and rescans when the file watcher reports
changes under `assets/`, so importing an asset is just **dropping the file into changes under `assets/`, so importing an asset is as simple as **dropping the
the matching typed folder** (`fonts/`, `textures/`, …) — no separate import file anywhere under `assets/`** — no separate import step. An asset-reference
step. The **Project panel** is an asset browser listing each typed folder's field in the inspector renders as a **picker** populated from the database,
assets from the database, and an asset-reference field in the inspector renders filtered to the field's target kind. New projects are seeded with a bundled
as a **picker** populated from it, filtered to the field's target kind. New default UI font (Inter, SIL OFL) under `fonts/`, referenced by its
projects are seeded with a bundled default UI font (Inter, SIL OFL) under project-relative path like any other asset.
`fonts/`, referenced by its project-relative path like any other asset.
The **Project panel** hosts a Unity-style **file explorer** over `assets/`
(`oxide_editor::explorer` holds the egui-free behavior layer; the shell only
renders it):
- **breadcrumbs + folder navigation** (double-click a folder, click a crumb);
- ** New Folder**, and per-row context menus with **Rename** and **Delete**
(folders delete only when empty — recursive asset deletion is deliberately
not offered);
- **drag a row onto a folder** (or the `..` row) to move it;
- **drag files in from the OS** to import them into the current folder
(copied in under a collision-free name, registered, manifest saved);
- **double-click a file** to open it — scripts via the external-editor flow
(see [scripting.md](scripting.md)), everything else via `xdg-open`.
Renames and moves go through the database's uid-preserving file ops, so saved
`AssetRef`s keep resolving after any reorganisation; unregistered files
(licenses, notes) fall back to plain filesystem operations. File operations
act immediately and bypass the undo stack, like the hierarchy's structural
edits.
[`AssetDatabase`]: ../engine/src/asset/database.rs [`AssetDatabase`]: ../engine/src/asset/database.rs
[`AssetDbError`]: ../engine/src/asset/database.rs [`AssetDbError`]: ../engine/src/asset/database.rs
+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();
}
}
+1
View File
@@ -16,6 +16,7 @@ pub mod bindings;
pub mod command; pub mod command;
pub mod commands; pub mod commands;
pub mod console; pub mod console;
pub mod explorer;
pub mod extension; pub mod extension;
pub mod gizmo; pub mod gizmo;
pub mod play; pub mod play;
+354 -61
View File
@@ -39,7 +39,7 @@ use std::path::PathBuf;
use std::time::Duration; use std::time::Duration;
use egui_dock::{DockArea, DockState, NodeIndex, Style}; use egui_dock::{DockArea, DockState, NodeIndex, Style};
use oxide_engine::asset::{asset_ref_target, AssetDatabase, AssetEntry}; use oxide_engine::asset::{asset_ref_target, AssetDatabase};
use oxide_engine::input::{Binding, InputState}; use oxide_engine::input::{Binding, InputState};
use oxide_engine::prelude::*; use oxide_engine::prelude::*;
use oxide_engine::project::{Project, ProjectError}; use oxide_engine::project::{Project, ProjectError};
@@ -104,6 +104,17 @@ impl PanelKind {
/// despawn / reparent). Tracked here only because the hierarchy panel builds /// despawn / reparent). Tracked here only because the hierarchy panel builds
/// these while the UI closure runs and applies them after, the same idiom /// these while the UI closure runs and applies them after, the same idiom
/// the Stage-5 main.rs used. /// the Stage-5 main.rs used.
/// Drag payload for a Project-panel explorer row: what is being moved.
/// Dropping it on a folder row (or the ".." row) moves the file/folder there
/// through the database's uid-preserving ops.
#[derive(Clone)]
struct ExplorerDragPayload {
/// Assets-relative path of the dragged entry.
rel: String,
/// Whether it is a folder.
is_dir: bool,
}
/// Which path field a finished native folder pick fills in. /// Which path field a finished native folder pick fills in.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FolderPickTarget { enum FolderPickTarget {
@@ -555,6 +566,10 @@ pub struct Shell {
/// pick) versus over an opaque panel. /// pick) versus over an opaque panel.
viewport_rect_px: Option<(f32, f32, f32, f32)>, viewport_rect_px: Option<(f32, f32, f32, f32)>,
/// The Project panel's file-explorer state (current folder, in-progress
/// rename / new-folder edits). Behavior lives in [`crate::explorer`].
explorer: crate::explorer::ExplorerState,
// --- per-frame UI scratch ----------------------------------------- // --- per-frame UI scratch -----------------------------------------
pending: Vec<PendingAction>, pending: Vec<PendingAction>,
rename_buf: String, rename_buf: String,
@@ -640,6 +655,7 @@ impl Shell {
new_project_name: String::new(), new_project_name: String::new(),
open_project_path: String::new(), open_project_path: String::new(),
folder_pick: None, folder_pick: None,
explorer: crate::explorer::ExplorerState::default(),
new_group_name: String::new(), new_group_name: String::new(),
new_script_name: String::new(), new_script_name: String::new(),
terminal_input: String::new(), terminal_input: String::new(),
@@ -1146,6 +1162,7 @@ impl Shell {
extensions: &mut self.extensions, extensions: &mut self.extensions,
pending: &mut self.pending, pending: &mut self.pending,
rename_buf: &mut self.rename_buf, rename_buf: &mut self.rename_buf,
explorer: &mut self.explorer,
new_script_name: &mut self.new_script_name, new_script_name: &mut self.new_script_name,
terminal_input: &mut self.terminal_input, terminal_input: &mut self.terminal_input,
rot_euler: &mut self.rot_euler, rot_euler: &mut self.rot_euler,
@@ -2295,6 +2312,9 @@ struct ShellTabViewer<'a> {
extensions: &'a mut EditorExtensions, extensions: &'a mut EditorExtensions,
pending: &'a mut Vec<PendingAction>, pending: &'a mut Vec<PendingAction>,
rename_buf: &'a mut String, rename_buf: &'a mut String,
/// The Project panel's file-explorer state. Borrowed from
/// [`Shell::explorer`].
explorer: &'a mut crate::explorer::ExplorerState,
/// The Script inspector's "New Script" name field. Borrowed from /// The Script inspector's "New Script" name field. Borrowed from
/// [`Shell::new_script_name`]. /// [`Shell::new_script_name`].
new_script_name: &'a mut String, new_script_name: &'a mut String,
@@ -3984,10 +4004,11 @@ impl<'a> ShellTabViewer<'a> {
ui.monospace(project.root().display().to_string()); ui.monospace(project.root().display().to_string());
ui.separator(); ui.separator();
// Asset browser: typed folders grouped by kind, listed from the // File explorer over assets/ (Stage-10 editor-UX): breadcrumbs,
// database (the source of truth for what the picker can reference). // folder navigation, rename/delete/move, and OS drag-in import — all
// "Importing" an asset is just dropping the file into the right folder; // over the AssetDatabase so every operation keeps uids (and saved
// the file watcher rescans, or the user can rescan on demand. // AssetRefs) intact. Importing also still works by dropping a file
// into assets/ on disk; the watcher or Rescan registers it.
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.label("Assets"); ui.label("Assets");
if ui if ui
@@ -4000,32 +4021,16 @@ impl<'a> ShellTabViewer<'a> {
let _ = db.save(); let _ = db.save();
} }
} }
if ui
.small_button(" New Folder")
.on_hover_text("Create a folder in the current directory")
.clicked()
{
self.explorer.new_folder = Some(String::new());
self.explorer.focus_field = true;
}
}); });
let open_asset = match &self.state.asset_db { self.assets_explorer(ui);
Some(db) => asset_browser(ui, db),
None => {
ui.weak("(asset database unavailable)");
None
}
};
// Double-click opens the asset: scripts through the external-editor
// flow (preference / $VISUAL/$EDITOR terminal tab), everything else
// via the desktop's default handler.
if let Some(uid) = open_asset {
let (kind, abs) = {
let db = self
.state
.asset_db
.as_ref()
.expect("open_asset came from it");
(db.entry(uid).map(|e| e.kind), db.absolute_path(uid))
};
if kind == Some(AssetKind::Script) {
self.open_script_in_editor(uid);
} else if let Some(abs) = abs {
spawn_detached("xdg-open", &abs);
}
}
ui.separator(); ui.separator();
let project = self.state.project.as_ref().unwrap(); let project = self.state.project.as_ref().unwrap();
@@ -4033,6 +4038,325 @@ impl<'a> ShellTabViewer<'a> {
show_project_tree(ui, "scripts/", project.scripts_dir()); show_project_tree(ui, "scripts/", project.scripts_dir());
} }
/// The Unity-style file explorer over `assets/`: breadcrumbs, folder
/// navigation (double-click), inline rename / new-folder rows, per-row
/// context menus, drag-to-move between folders, and OS drag-in import
/// into the current folder. All behavior lives in [`crate::explorer`];
/// this only renders it and collects intents (applied after the render
/// pass, so no closure ever borrows `self.state` and the explorer
/// buffers at once). File operations act immediately and bypass the undo
/// stack — like the hierarchy's structural edits — but every move goes
/// through the database's uid-preserving ops, so saved `AssetRef`s keep
/// resolving.
fn assets_explorer(&mut self, ui: &mut egui::Ui) {
use crate::explorer::{self as exp, Entry};
let Some(db) = &self.state.asset_db else {
ui.weak("(asset database unavailable)");
return;
};
let cwd = self.explorer.cwd.clone();
let entries = exp::list_dir(db, &cwd);
let crumbs = exp::breadcrumbs(&cwd);
// Intents collected during render, applied afterwards.
let mut nav: Option<String> = None;
let mut open: Option<Entry> = None;
let mut start_rename: Option<Entry> = None;
let mut delete: Option<Entry> = None;
// (what to move: rel + is_dir, destination folder)
let mut moved: Option<(String, bool, String)> = None;
let mut commit_rename = false;
let mut cancel_rename = false;
let mut commit_new_folder = false;
let mut cancel_new_folder = false;
// Breadcrumbs: assets / textures / env — click a segment to jump.
ui.horizontal_wrapped(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
for (i, (label, target)) in crumbs.iter().enumerate() {
if i > 0 {
ui.weak("/");
}
let here = *target == cwd;
if ui.selectable_label(here, label).clicked() && !here {
nav = Some(target.clone());
}
}
});
// Drag state shared by every drop target this frame.
let dragged = egui::DragAndDrop::payload::<ExplorerDragPayload>(ui.ctx());
let pointer = ui.ctx().pointer_interact_pos();
let released = ui.input(|i| i.pointer.any_released());
let accent = ui.visuals().selection.bg_fill;
let escape = ui.input(|i| i.key_pressed(egui::Key::Escape));
let focus_now = std::mem::take(&mut self.explorer.focus_field);
egui::ScrollArea::vertical()
.id_salt("oxide.explorer")
.auto_shrink([false, true])
.show(ui, |ui| {
// Inline "New Folder" row.
if let Some(buf) = &mut self.explorer.new_folder {
ui.horizontal(|ui| {
ui.label("");
let resp = ui.add(
egui::TextEdit::singleline(buf)
.hint_text("folder name")
.desired_width(160.0),
);
if focus_now {
resp.request_focus();
}
if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
commit_new_folder = true;
}
if ui.small_button("Create").clicked() {
commit_new_folder = true;
}
if escape {
cancel_new_folder = true;
}
});
}
// ".." row: double-click to go up; also a drop target that
// moves items into the parent folder.
if !cwd.is_empty() {
let up = exp::parent_of(&cwd);
let resp = ui
.add(
egui::Label::new("▸ ..")
.selectable(false)
.sense(egui::Sense::click()),
)
.on_hover_text("Double-click to go up — drop items here to move them up");
if resp.double_clicked() {
nav = Some(up.clone());
}
if let (Some(payload), Some(pos)) = (&dragged, pointer) {
if resp.rect.contains(pos) {
stroke_rect(
ui.painter(),
resp.rect,
egui::Stroke::new(1.5_f32, accent),
);
if released {
moved = Some((payload.rel.clone(), payload.is_dir, up.clone()));
}
}
}
}
if entries.is_empty() && self.explorer.new_folder.is_none() {
ui.weak("(empty — drag files in from your file manager to import)");
}
for entry in &entries {
// An entry mid-rename renders as a text field instead.
let renaming = self
.explorer
.rename
.as_ref()
.is_some_and(|r| r.rel == entry.rel);
if renaming {
let buf = &mut self.explorer.rename.as_mut().expect("checked").buf;
ui.horizontal(|ui| {
let resp = ui.add(egui::TextEdit::singleline(buf).desired_width(180.0));
if focus_now {
resp.request_focus();
}
if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
commit_rename = true;
}
if ui.small_button("Rename").clicked() {
commit_rename = true;
}
if escape {
cancel_rename = true;
}
});
continue;
}
let display = if entry.is_dir {
format!("{}/", entry.name)
} else {
entry.name.clone()
};
let hover = match (entry.is_dir, entry.kind) {
(true, _) => format!(
"{}/\ndouble-click to open · drag to move · right-click for actions",
entry.rel
),
(false, Some(kind)) => format!(
"{}\n{kind:?} asset · double-click to open · drag to move",
entry.rel
),
(false, None) => {
format!("{}\nunregistered file · drag to move", entry.rel)
}
};
// One widget senses click + drag (same pattern as the
// hierarchy rows): still press = click, press+move = drag.
let resp = ui
.add(
egui::Label::new(display)
.selectable(false)
.sense(egui::Sense::click_and_drag()),
)
.on_hover_text(hover);
if resp.drag_started() {
egui::DragAndDrop::set_payload(
ui.ctx(),
ExplorerDragPayload {
rel: entry.rel.clone(),
is_dir: entry.is_dir,
},
);
}
if resp.dragged() {
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
}
if resp.double_clicked() {
if entry.is_dir {
nav = Some(entry.rel.clone());
} else {
open = Some(entry.clone());
}
}
// Folder rows accept drops (not from themselves).
if entry.is_dir {
if let (Some(payload), Some(pos)) = (&dragged, pointer) {
if payload.rel != entry.rel && resp.rect.contains(pos) {
stroke_rect(
ui.painter(),
resp.rect,
egui::Stroke::new(1.5_f32, accent),
);
if released {
moved = Some((
payload.rel.clone(),
payload.is_dir,
entry.rel.clone(),
));
}
}
}
}
resp.context_menu(|ui| {
if ui.button("Open").clicked() {
if entry.is_dir {
nav = Some(entry.rel.clone());
} else {
open = Some(entry.clone());
}
ui.close();
}
if ui.button("✏ Rename").clicked() {
start_rename = Some(entry.clone());
ui.close();
}
ui.separator();
if ui.button("🗑 Delete").clicked() {
delete = Some(entry.clone());
ui.close();
}
});
}
});
// OS drag-in import: while files hover the window, hint; when they
// drop (and the Project panel is showing), copy + register them into
// the current folder.
if ui.input(|i| !i.raw.hovered_files.is_empty()) {
ui.colored_label(accent, format!("Drop to import into assets/{cwd}"));
}
let dropped: Vec<PathBuf> = ui.input(|i| {
i.raw
.dropped_files
.iter()
.filter_map(|f| f.path.clone())
.collect()
});
// ---- apply the collected intents ---------------------------------
if cancel_new_folder {
self.explorer.new_folder = None;
} else if commit_new_folder {
let wanted = self.explorer.new_folder.take().unwrap_or_default();
if let Some(db) = &self.state.asset_db {
match exp::create_folder(db, &cwd, wanted.trim()) {
Ok(rel) => log::info!("created folder assets/{rel}"),
Err(err) => log::warn!("could not create folder: {err}"),
}
}
}
if cancel_rename {
self.explorer.rename = None;
} else if commit_rename {
if let Some(edit) = self.explorer.rename.take() {
if let Some(db) = self.state.asset_db.as_mut() {
match exp::rename_entry(db, &edit.rel, edit.is_dir, edit.buf.trim()) {
Ok(new_rel) => log::info!("renamed {} -> {new_rel}", edit.rel),
Err(err) => log::warn!("could not rename {}: {err}", edit.rel),
}
}
}
}
if let Some(entry) = start_rename {
self.explorer.rename = Some(crate::explorer::RenameEdit {
rel: entry.rel,
is_dir: entry.is_dir,
buf: entry.name,
});
self.explorer.focus_field = true;
}
if let Some((rel, is_dir, dest)) = moved {
if let Some(db) = self.state.asset_db.as_mut() {
match exp::move_entry(db, &rel, is_dir, &dest) {
Ok(new_rel) => log::info!("moved {rel} -> {new_rel}"),
Err(err) => log::warn!("could not move {rel}: {err}"),
}
}
}
if let Some(entry) = delete {
if let Some(db) = self.state.asset_db.as_mut() {
match exp::delete_entry(db, &entry) {
Ok(()) => log::info!("deleted {}", entry.rel),
Err(err) => log::warn!("could not delete {}: {err}", entry.rel),
}
}
}
if let Some(entry) = open {
if entry.kind == Some(AssetKind::Script) {
if let Some(uid) = entry.uid {
self.open_script_in_editor(uid);
}
} else {
let abs = self.state.asset_db.as_ref().map(|db| {
db.assets_dir()
.join(entry.rel.replace('/', std::path::MAIN_SEPARATOR_STR))
});
if let Some(abs) = abs {
spawn_detached("xdg-open", &abs);
}
}
}
if !dropped.is_empty() {
if let Some(db) = self.state.asset_db.as_mut() {
let n = exp::import_files(db, &cwd, &dropped);
if n > 0 {
log::info!("imported {n} file(s) into assets/{cwd}");
}
}
}
if let Some(target) = nav {
self.explorer.navigate(target);
}
}
/// The UI canvas: edit a [`UiPanel`] document — widget tree, live preview, /// The UI canvas: edit a [`UiPanel`] document — widget tree, live preview,
/// and a property panel (including the font-asset picker) — saved as a /// and a property panel (including the font-asset picker) — saved as a
/// `ui/` asset. Structural and property edits route through /// `ui/` asset. Structural and property edits route through
@@ -5040,10 +5364,6 @@ fn snapshot_node(
} }
} }
/// Renders the typed asset folders (one collapsing section per [`AssetKind`])
/// from the database, listing each asset by its name. Drop a file into the
/// matching folder on disk to import it; the watcher (or the Rescan button)
/// registers it.
/// The colour for a console log line of the given severity: errors red, warnings /// The colour for a console log line of the given severity: errors red, warnings
/// amber, info the normal text colour, debug/trace dimmed. /// amber, info the normal text colour, debug/trace dimmed.
fn level_color(ui: &egui::Ui, level: log::Level) -> egui::Color32 { fn level_color(ui: &egui::Ui, level: log::Level) -> egui::Color32 {
@@ -5114,33 +5434,6 @@ fn build_terminal_job(
job job
} }
fn asset_browser(ui: &mut egui::Ui, db: &AssetDatabase) -> Option<AssetUid> {
let mut open = None;
for kind in AssetKind::TYPED {
let mut entries: Vec<&AssetEntry> = db.entries_of_kind(kind).collect();
entries.sort_by(|a, b| a.path.cmp(&b.path));
let header = format!("{}/ ({})", kind.folder(), entries.len());
egui::CollapsingHeader::new(header)
.id_salt(("oxide.assets", kind.folder()))
.show(ui, |ui| {
if entries.is_empty() {
ui.weak("(empty — drop files here)");
}
for entry in entries {
// Show the leaf name; the full relative path on hover.
let leaf = entry.path.rsplit('/').next().unwrap_or(&entry.path);
let resp = ui
.add(egui::Label::new(leaf).sense(egui::Sense::click()))
.on_hover_text(format!("{}\n(double-click to open)", entry.path));
if resp.double_clicked() {
open = Some(entry.uid);
}
}
});
}
open
}
/// A short label for a widget in the tree / property header: its id if set, /// A short label for a widget in the tree / property header: its id if set,
/// else its kind, with the root marked. /// else its kind, with the root marked.
fn widget_label(widget: &Widget, path: &WidgetPath) -> String { fn widget_label(widget: &Widget, path: &WidgetPath) -> String {
+29 -12
View File
@@ -440,18 +440,21 @@ impl AssetDatabase {
uid uid
} }
/// Scans the typed asset folders under `assets/` and reconciles the /// Scans everything under `assets/` and reconciles the database with what
/// database with what is on disk: existing files keep their uid, new files /// is on disk: existing files keep their uid, new files are
/// are [registered](Self::register), and entries whose files no longer exist /// [registered](Self::register), and entries whose files no longer exist
/// are dropped. Returns the number of newly registered assets. /// 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. /// Call [`save`](Self::save) afterwards to persist any new uids.
pub fn scan(&mut self) -> usize { pub fn scan(&mut self) -> usize {
let assets_dir = self.assets_dir(); let assets_dir = self.assets_dir();
let mut found: Vec<String> = Vec::new(); let mut found: Vec<String> = Vec::new();
for kind in AssetKind::TYPED { collect_files(&assets_dir, &assets_dir, &mut found);
collect_files(&assets_dir.join(kind.folder()), &assets_dir, &mut found);
}
// Drop entries whose backing file disappeared. // Drop entries whose backing file disappeared.
let present: std::collections::HashSet<&String> = found.iter().collect(); let present: std::collections::HashSet<&String> = found.iter().collect();
@@ -811,12 +814,27 @@ mod tests {
assert_eq!(db.len(), 3); assert_eq!(db.len(), 3);
assert_eq!(db.entries_of_kind(AssetKind::Font).count(), 1); 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. // Remove one file and rescan: it is pruned, the rest keep their uids.
let font_uid = db.uid_of("fonts/Inter.ttf").unwrap(); let font_uid = db.uid_of("fonts/Inter.ttf").unwrap();
let wall_uid = db.uid_of("textures/wall.png").unwrap(); let wall_uid = db.uid_of("textures/wall.png").unwrap();
std::fs::remove_file(root.join(ASSETS_DIR).join("fonts/Inter.ttf")).unwrap(); std::fs::remove_file(root.join(ASSETS_DIR).join("fonts/Inter.ttf")).unwrap();
assert_eq!(db.scan(), 0); 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!(db.entry(font_uid).is_none());
assert_eq!(db.uid_of("textures/wall.png"), Some(wall_uid)); 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(); db.move_asset(uid, "misc/brick.dat").unwrap();
assert_eq!(db.entry(uid).unwrap().kind, AssetKind::Other); assert_eq!(db.entry(uid).unwrap().kind, AssetKind::Other);
// A rescan does not disturb the moved entry's uid... (misc/ is not a // A rescan (which walks the whole tree) does not disturb the moved
// typed folder, so the entry survives only because scan never saw it — // entry's uid, even outside the typed folders.
// move back first to prove the typed-folder case.)
db.move_asset(uid, "textures/brick.png").unwrap();
db.scan(); 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. // No-op and error cases.
db.move_asset(uid, "textures/brick.png").unwrap(); // unchanged path: Ok db.move_asset(uid, "textures/brick.png").unwrap(); // unchanged path: Ok