From c47efa876fad1b89d0fb597dc60be6b800f037a5 Mon Sep 17 00:00:00 2001 From: Homer Date: Fri, 10 Jul 2026 21:01:05 +0200 Subject: [PATCH] Editor: Unity-style file explorer in the Project panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- PLAN.md | 6 + docs/assets.md | 35 ++- editor/src/explorer.rs | 524 +++++++++++++++++++++++++++++++++++ editor/src/lib.rs | 1 + editor/src/shell.rs | 415 +++++++++++++++++++++++---- engine/src/asset/database.rs | 41 ++- 6 files changed, 941 insertions(+), 81 deletions(-) create mode 100644 editor/src/explorer.rs diff --git a/PLAN.md b/PLAN.md index 2eca892..10e7ee9 100644 --- a/PLAN.md +++ b/PLAN.md @@ -676,6 +676,12 @@ console)**. Each is GUI โ†’ `dev` + eye-check. menus, breadcrumb navigation. The `AssetDatabase` already tracks arbitrary relative paths, so this is a UI/interaction layer over it (re-classify by 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 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 diff --git a/docs/assets.md b/docs/assets.md index c9eaee9..5d2eba5 100644 --- a/docs/assets.md +++ b/docs/assets.md @@ -138,7 +138,7 @@ gives an absolute path the server loads and deduplicates. use oxide_engine::asset::{AssetDatabase, AssetServer, GltfModel}; 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 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 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 -the matching typed folder** (`fonts/`, `textures/`, โ€ฆ) โ€” no separate import -step. The **Project panel** is an asset browser listing each typed folder's -assets from the database, and an asset-reference field in the inspector renders -as a **picker** populated from it, filtered to the field's target kind. New -projects are seeded with a bundled default UI font (Inter, SIL OFL) under -`fonts/`, referenced by its project-relative path like any other asset. +changes under `assets/`, so importing an asset is as simple as **dropping the +file anywhere under `assets/`** โ€” no separate import step. An asset-reference +field in the inspector renders as a **picker** populated from the database, +filtered to the field's target kind. New projects are seeded with a bundled +default UI font (Inter, SIL OFL) under `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 [`AssetDbError`]: ../engine/src/asset/database.rs diff --git a/editor/src/explorer.rs b/editor/src/explorer.rs new file mode 100644 index 0000000..4a6c27e --- /dev/null +++ b/editor/src/explorer.rs @@ -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, + /// 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(); + } +} diff --git a/editor/src/lib.rs b/editor/src/lib.rs index 98fd183..4801d73 100644 --- a/editor/src/lib.rs +++ b/editor/src/lib.rs @@ -16,6 +16,7 @@ pub mod bindings; pub mod command; pub mod commands; pub mod console; +pub mod explorer; pub mod extension; pub mod gizmo; pub mod play; diff --git a/editor/src/shell.rs b/editor/src/shell.rs index 872c7b0..d84a7d5 100644 --- a/editor/src/shell.rs +++ b/editor/src/shell.rs @@ -39,7 +39,7 @@ use std::path::PathBuf; use std::time::Duration; 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::prelude::*; use oxide_engine::project::{Project, ProjectError}; @@ -104,6 +104,17 @@ impl PanelKind { /// despawn / reparent). Tracked here only because the hierarchy panel builds /// these while the UI closure runs and applies them after, the same idiom /// 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. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum FolderPickTarget { @@ -555,6 +566,10 @@ pub struct Shell { /// pick) versus over an opaque panel. 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 ----------------------------------------- pending: Vec, rename_buf: String, @@ -640,6 +655,7 @@ impl Shell { new_project_name: String::new(), open_project_path: String::new(), folder_pick: None, + explorer: crate::explorer::ExplorerState::default(), new_group_name: String::new(), new_script_name: String::new(), terminal_input: String::new(), @@ -1146,6 +1162,7 @@ impl Shell { extensions: &mut self.extensions, pending: &mut self.pending, rename_buf: &mut self.rename_buf, + explorer: &mut self.explorer, new_script_name: &mut self.new_script_name, terminal_input: &mut self.terminal_input, rot_euler: &mut self.rot_euler, @@ -2295,6 +2312,9 @@ struct ShellTabViewer<'a> { extensions: &'a mut EditorExtensions, pending: &'a mut Vec, 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 /// [`Shell::new_script_name`]. new_script_name: &'a mut String, @@ -3984,10 +4004,11 @@ impl<'a> ShellTabViewer<'a> { ui.monospace(project.root().display().to_string()); ui.separator(); - // Asset browser: typed folders grouped by kind, listed from the - // database (the source of truth for what the picker can reference). - // "Importing" an asset is just dropping the file into the right folder; - // the file watcher rescans, or the user can rescan on demand. + // File explorer over assets/ (Stage-10 editor-UX): breadcrumbs, + // folder navigation, rename/delete/move, and OS drag-in import โ€” all + // over the AssetDatabase so every operation keeps uids (and saved + // AssetRefs) intact. Importing also still works by dropping a file + // into assets/ on disk; the watcher or Rescan registers it. ui.horizontal(|ui| { ui.label("Assets"); if ui @@ -4000,32 +4021,16 @@ impl<'a> ShellTabViewer<'a> { 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 { - 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); - } - } + self.assets_explorer(ui); ui.separator(); let project = self.state.project.as_ref().unwrap(); @@ -4033,6 +4038,325 @@ impl<'a> ShellTabViewer<'a> { 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 = None; + let mut open: Option = None; + let mut start_rename: Option = None; + let mut delete: Option = 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::(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 = 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, /// and a property panel (including the font-asset picker) โ€” saved as a /// `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 /// amber, info the normal text colour, debug/trace dimmed. fn level_color(ui: &egui::Ui, level: log::Level) -> egui::Color32 { @@ -5114,33 +5434,6 @@ fn build_terminal_job( job } -fn asset_browser(ui: &mut egui::Ui, db: &AssetDatabase) -> Option { - 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, /// else its kind, with the root marked. fn widget_label(widget: &Widget, path: &WidgetPath) -> String { diff --git a/engine/src/asset/database.rs b/engine/src/asset/database.rs index 23609d4..f90a463 100644 --- a/engine/src/asset/database.rs +++ b/engine/src/asset/database.rs @@ -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 = 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