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
+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 {