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