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
+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 commands;
pub mod console;
pub mod explorer;
pub mod extension;
pub mod gizmo;
pub mod play;
+354 -61
View File
@@ -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<PendingAction>,
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<PendingAction>,
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<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,
/// 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<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,
/// else its kind, with the root marked.
fn widget_label(widget: &Widget, path: &WidgetPath) -> String {