Compare commits
4 Commits
072a7c0b56
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| bad780de9d | |||
| 3c59faa506 | |||
| 608898411a | |||
| fd25677631 |
@@ -45,6 +45,10 @@ gltf = { version = "1.4", features = ["utils"] }
|
|||||||
ab_glyph = "0.2"
|
ab_glyph = "0.2"
|
||||||
|
|
||||||
# Editor UI (egui — integrated into oxide-editor only)
|
# Editor UI (egui — integrated into oxide-editor only)
|
||||||
|
# Native file/folder dialogs (New/Open Project). The default `xdg-portal`
|
||||||
|
# backend is pure Rust and talks to xdg-desktop-portal over D-Bus, so one
|
||||||
|
# build serves both Wayland and X11 with no GTK link-time dependency.
|
||||||
|
rfd = "0.15"
|
||||||
egui = "0.34"
|
egui = "0.34"
|
||||||
egui-wgpu = "0.34"
|
egui-wgpu = "0.34"
|
||||||
egui-winit = "0.34"
|
egui-winit = "0.34"
|
||||||
|
|||||||
@@ -22,9 +22,15 @@ egui.workspace = true
|
|||||||
egui-wgpu.workspace = true
|
egui-wgpu.workspace = true
|
||||||
egui-winit.workspace = true
|
egui-winit.workspace = true
|
||||||
egui_dock.workspace = true
|
egui_dock.workspace = true
|
||||||
|
# Native folder picker for New/Open Project, run on a helper thread so the
|
||||||
|
# UI keeps redrawing while the dialog is up (see Shell::poll_folder_pick).
|
||||||
|
rfd.workspace = true
|
||||||
# Editor preferences file I/O reads/writes the same RON shape `Settings`
|
# Editor preferences file I/O reads/writes the same RON shape `Settings`
|
||||||
# exports; the engine already pulls `ron` in, the editor now does too.
|
# exports; the engine already pulls `ron` in, the editor now does too.
|
||||||
ron.workspace = true
|
ron.workspace = true
|
||||||
|
# Editor-owned settings sections (e.g. the External Editor preference) derive
|
||||||
|
# their own Serialize/Deserialize for the Settings store.
|
||||||
|
serde.workspace = true
|
||||||
|
|
||||||
# PTY-backed terminal panel (Stage 10): run interactive/TUI programs — shells,
|
# PTY-backed terminal panel (Stage 10): run interactive/TUI programs — shells,
|
||||||
# REPLs, and AI-agent CLIs like `claude` — inside the editor. `portable-pty`
|
# REPLs, and AI-agent CLIs like `claude` — inside the editor. `portable-pty`
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ pub mod bindings;
|
|||||||
pub mod command;
|
pub mod command;
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod console;
|
pub mod console;
|
||||||
|
pub mod explorer;
|
||||||
pub mod extension;
|
pub mod extension;
|
||||||
pub mod gizmo;
|
pub mod gizmo;
|
||||||
pub mod play;
|
pub mod play;
|
||||||
|
|||||||
+662
-41
@@ -39,7 +39,7 @@ use std::path::PathBuf;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use egui_dock::{DockArea, DockState, NodeIndex, Style};
|
use egui_dock::{DockArea, DockState, NodeIndex, Style};
|
||||||
use oxide_engine::asset::{asset_ref_target, AssetDatabase, AssetEntry};
|
use oxide_engine::asset::{asset_ref_target, AssetDatabase};
|
||||||
use oxide_engine::input::{Binding, InputState};
|
use oxide_engine::input::{Binding, InputState};
|
||||||
use oxide_engine::prelude::*;
|
use oxide_engine::prelude::*;
|
||||||
use oxide_engine::project::{Project, ProjectError};
|
use oxide_engine::project::{Project, ProjectError};
|
||||||
@@ -52,7 +52,7 @@ use crate::command::{Command, CommandStack};
|
|||||||
use crate::commands::{RenameCmd, SetFieldCmd, SetUiPanelCmd};
|
use crate::commands::{RenameCmd, SetFieldCmd, SetUiPanelCmd};
|
||||||
use crate::extension::EditorExtensions;
|
use crate::extension::EditorExtensions;
|
||||||
use crate::gizmo::{self, Axis3, GizmoHandle, GizmoMode, PlaneAxis};
|
use crate::gizmo::{self, Axis3, GizmoHandle, GizmoMode, PlaneAxis};
|
||||||
use crate::state::{EditorState, PlayState};
|
use crate::state::{EditorState, ExternalEditorPrefs, PlayState, EXTERNAL_EDITOR_SECTION};
|
||||||
use oxide_engine::math::{Mat4, Vec4};
|
use oxide_engine::math::{Mat4, Vec4};
|
||||||
use oxide_engine::reflect::FieldInfo;
|
use oxide_engine::reflect::FieldInfo;
|
||||||
|
|
||||||
@@ -104,6 +104,26 @@ impl PanelKind {
|
|||||||
/// despawn / reparent). Tracked here only because the hierarchy panel builds
|
/// despawn / reparent). Tracked here only because the hierarchy panel builds
|
||||||
/// these while the UI closure runs and applies them after, the same idiom
|
/// these while the UI closure runs and applies them after, the same idiom
|
||||||
/// the Stage-5 main.rs used.
|
/// the Stage-5 main.rs used.
|
||||||
|
/// Drag payload for a Project-panel explorer row: what is being moved.
|
||||||
|
/// Dropping it on a folder row (or the ".." row) moves the file/folder there
|
||||||
|
/// through the database's uid-preserving ops.
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct ExplorerDragPayload {
|
||||||
|
/// Assets-relative path of the dragged entry.
|
||||||
|
rel: String,
|
||||||
|
/// Whether it is a folder.
|
||||||
|
is_dir: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which path field a finished native folder pick fills in.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum FolderPickTarget {
|
||||||
|
/// The "New Project" dialog's project-folder field.
|
||||||
|
NewProject,
|
||||||
|
/// The "Open Project" dialog's path field.
|
||||||
|
OpenProject,
|
||||||
|
}
|
||||||
|
|
||||||
enum PendingAction {
|
enum PendingAction {
|
||||||
/// Spawn a named prefab as a root entity (data-driven add-menu). The
|
/// Spawn a named prefab as a root entity (data-driven add-menu). The
|
||||||
/// `Empty` prefab is a bare node; the rest carry components.
|
/// `Empty` prefab is a bare node; the rest carry components.
|
||||||
@@ -523,8 +543,15 @@ pub struct Shell {
|
|||||||
new_project_name: String,
|
new_project_name: String,
|
||||||
/// Text entered in the "Open Project" modal.
|
/// Text entered in the "Open Project" modal.
|
||||||
open_project_path: String,
|
open_project_path: String,
|
||||||
|
/// An in-flight native folder pick (Browse… in New/Open Project): which
|
||||||
|
/// field the result lands in + the channel the dialog thread reports on.
|
||||||
|
/// The dialog runs on its own thread so the UI keeps redrawing; `None`
|
||||||
|
/// means no pick is up (and gates the Browse buttons to one at a time).
|
||||||
|
folder_pick: Option<(FolderPickTarget, std::sync::mpsc::Receiver<Option<PathBuf>>)>,
|
||||||
/// Text entered in the "Groups" editor's "add group" field.
|
/// Text entered in the "Groups" editor's "add group" field.
|
||||||
new_group_name: String,
|
new_group_name: String,
|
||||||
|
/// Text entered in the Script inspector's "New Script" name field.
|
||||||
|
new_script_name: String,
|
||||||
/// The Console panel's command-input buffer (the terminal prompt).
|
/// The Console panel's command-input buffer (the terminal prompt).
|
||||||
terminal_input: String,
|
terminal_input: String,
|
||||||
|
|
||||||
@@ -539,6 +566,10 @@ pub struct Shell {
|
|||||||
/// pick) versus over an opaque panel.
|
/// pick) versus over an opaque panel.
|
||||||
viewport_rect_px: Option<(f32, f32, f32, f32)>,
|
viewport_rect_px: Option<(f32, f32, f32, f32)>,
|
||||||
|
|
||||||
|
/// The Project panel's file-explorer state (current folder, in-progress
|
||||||
|
/// rename / new-folder edits). Behavior lives in [`crate::explorer`].
|
||||||
|
explorer: crate::explorer::ExplorerState,
|
||||||
|
|
||||||
// --- per-frame UI scratch -----------------------------------------
|
// --- per-frame UI scratch -----------------------------------------
|
||||||
pending: Vec<PendingAction>,
|
pending: Vec<PendingAction>,
|
||||||
rename_buf: String,
|
rename_buf: String,
|
||||||
@@ -623,7 +654,10 @@ impl Shell {
|
|||||||
new_project_path: String::new(),
|
new_project_path: String::new(),
|
||||||
new_project_name: String::new(),
|
new_project_name: String::new(),
|
||||||
open_project_path: String::new(),
|
open_project_path: String::new(),
|
||||||
|
folder_pick: None,
|
||||||
|
explorer: crate::explorer::ExplorerState::default(),
|
||||||
new_group_name: String::new(),
|
new_group_name: String::new(),
|
||||||
|
new_script_name: String::new(),
|
||||||
terminal_input: String::new(),
|
terminal_input: String::new(),
|
||||||
quit_requested: false,
|
quit_requested: false,
|
||||||
viewport_rect_px: None,
|
viewport_rect_px: None,
|
||||||
@@ -1100,6 +1134,9 @@ impl Shell {
|
|||||||
/// closure more than once during layout.
|
/// closure more than once during layout.
|
||||||
pub fn build(&mut self, ui: &mut egui::Ui) {
|
pub fn build(&mut self, ui: &mut egui::Ui) {
|
||||||
self.pending.clear();
|
self.pending.clear();
|
||||||
|
// Collect a native folder pick before the project dialogs render, so
|
||||||
|
// a chosen path appears in its field the same frame.
|
||||||
|
self.poll_folder_pick();
|
||||||
|
|
||||||
self.menu_bar(ui);
|
self.menu_bar(ui);
|
||||||
self.play_toolbar(ui);
|
self.play_toolbar(ui);
|
||||||
@@ -1125,6 +1162,8 @@ impl Shell {
|
|||||||
extensions: &mut self.extensions,
|
extensions: &mut self.extensions,
|
||||||
pending: &mut self.pending,
|
pending: &mut self.pending,
|
||||||
rename_buf: &mut self.rename_buf,
|
rename_buf: &mut self.rename_buf,
|
||||||
|
explorer: &mut self.explorer,
|
||||||
|
new_script_name: &mut self.new_script_name,
|
||||||
terminal_input: &mut self.terminal_input,
|
terminal_input: &mut self.terminal_input,
|
||||||
rot_euler: &mut self.rot_euler,
|
rot_euler: &mut self.rot_euler,
|
||||||
euler_for: &mut self.euler_for,
|
euler_for: &mut self.euler_for,
|
||||||
@@ -1459,6 +1498,8 @@ impl Shell {
|
|||||||
// section editor lands.
|
// section editor lands.
|
||||||
if name == crate::bindings::SETTINGS_SECTION {
|
if name == crate::bindings::SETTINGS_SECTION {
|
||||||
ui.collapsing("Input Bindings", |ui| self.input_bindings_page(ui));
|
ui.collapsing("Input Bindings", |ui| self.input_bindings_page(ui));
|
||||||
|
} else if name == EXTERNAL_EDITOR_SECTION {
|
||||||
|
ui.collapsing("External Editor", |ui| self.external_editor_page(ui));
|
||||||
} else {
|
} else {
|
||||||
let ron = self.state.settings.section_ron(name);
|
let ron = self.state.settings.section_ron(name);
|
||||||
ui.collapsing(name, |ui| match ron {
|
ui.collapsing(name, |ui| match ron {
|
||||||
@@ -1482,6 +1523,37 @@ impl Shell {
|
|||||||
self.show_preferences = open;
|
self.show_preferences = open;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The External Editor preferences page: the command used when opening a
|
||||||
|
/// script from the editor. Edits mark the preferences dirty so the host
|
||||||
|
/// persists them like a binding remap.
|
||||||
|
fn external_editor_page(&mut self, ui: &mut egui::Ui) {
|
||||||
|
let Some(prefs) = self
|
||||||
|
.state
|
||||||
|
.settings
|
||||||
|
.get_mut::<ExternalEditorPrefs>(EXTERNAL_EDITOR_SECTION)
|
||||||
|
else {
|
||||||
|
ui.weak("(section unavailable)");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Open scripts with");
|
||||||
|
if ui
|
||||||
|
.add(
|
||||||
|
egui::TextEdit::singleline(&mut prefs.command)
|
||||||
|
.hint_text("auto — $VISUAL/$EDITOR, else xdg-open"),
|
||||||
|
)
|
||||||
|
.changed()
|
||||||
|
{
|
||||||
|
self.bindings_dirty = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ui.weak(
|
||||||
|
"Launched as `<command> <file>` (the command may carry flags, e.g. `code -g`). \
|
||||||
|
Leave empty for auto: $VISUAL/$EDITOR runs in a built-in Terminal tab, \
|
||||||
|
otherwise the file opens via xdg-open.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Renders the input-bindings preferences page: every registered editor
|
/// Renders the input-bindings preferences page: every registered editor
|
||||||
/// action with its current bindings, a Change/Add/Clear control per
|
/// action with its current bindings, a Change/Add/Clear control per
|
||||||
/// slot, a per-action "Restore defaults" button, and a global
|
/// slot, a per-action "Restore defaults" button, and a global
|
||||||
@@ -1853,22 +1925,80 @@ impl Shell {
|
|||||||
self.show_group_editor = open;
|
self.show_group_editor = open;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// In-app New Project dialog: a path field, a name field, and Create / Cancel.
|
/// Launches the native folder picker on a helper thread, reporting into
|
||||||
/// A native OS file dialog (`rfd` or similar) is a piece-6 polish item — for
|
/// [`Shell::folder_pick`]. One pick at a time; the Browse buttons are
|
||||||
/// now the path is typed, which is enough to exercise the flow end-to-end and
|
/// disabled while one is up. `rfd`'s portal backend serves both Wayland
|
||||||
/// keeps the editor dependency-light.
|
/// and X11; if no portal service is running the thread reports `None`
|
||||||
|
/// (same as cancel) and the typed path field still works.
|
||||||
|
fn launch_folder_pick(&mut self, target: FolderPickTarget, start_dir: Option<PathBuf>) {
|
||||||
|
if self.folder_pick.is_some() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let mut dialog = rfd::FileDialog::new().set_title("Choose a project folder");
|
||||||
|
if let Some(dir) = start_dir.filter(|d| d.is_dir()) {
|
||||||
|
dialog = dialog.set_directory(dir);
|
||||||
|
}
|
||||||
|
// A dropped receiver (shell already gone) is fine to ignore.
|
||||||
|
let _ = tx.send(dialog.pick_folder());
|
||||||
|
});
|
||||||
|
self.folder_pick = Some((target, rx));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collects a finished native folder pick into its target path field.
|
||||||
|
/// Called once per frame from [`Shell::build`]; does nothing while the
|
||||||
|
/// dialog is still up (the thread hasn't reported).
|
||||||
|
fn poll_folder_pick(&mut self) {
|
||||||
|
use std::sync::mpsc::TryRecvError;
|
||||||
|
let Some((target, rx)) = self.folder_pick.take() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match rx.try_recv() {
|
||||||
|
// Picked: fill the field the dialog was opened for.
|
||||||
|
Ok(Some(path)) => {
|
||||||
|
let text = path.display().to_string();
|
||||||
|
match target {
|
||||||
|
FolderPickTarget::NewProject => self.new_project_path = text,
|
||||||
|
FolderPickTarget::OpenProject => self.open_project_path = text,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Cancelled (or the portal is unavailable): keep the typed text.
|
||||||
|
Ok(None) | Err(TryRecvError::Disconnected) => {}
|
||||||
|
// Still up: put it back and check again next frame.
|
||||||
|
Err(TryRecvError::Empty) => self.folder_pick = Some((target, rx)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-app New Project dialog: a folder field with a native Browse… picker,
|
||||||
|
/// a name field, and Create / Cancel. The path can still be typed by hand
|
||||||
|
/// (the picker needs a running xdg-desktop-portal to appear).
|
||||||
fn new_project_window(&mut self, ui: &mut egui::Ui) {
|
fn new_project_window(&mut self, ui: &mut egui::Ui) {
|
||||||
let ctx = ui.ctx().clone();
|
let ctx = ui.ctx().clone();
|
||||||
let mut open = self.show_new_project;
|
let mut open = self.show_new_project;
|
||||||
let mut create_now = false;
|
let mut create_now = false;
|
||||||
let mut cancel_now = false;
|
let mut cancel_now = false;
|
||||||
|
let mut browse_now = false;
|
||||||
|
let picking = self.folder_pick.is_some();
|
||||||
egui::Window::new("New Project")
|
egui::Window::new("New Project")
|
||||||
.open(&mut open)
|
.open(&mut open)
|
||||||
.default_size([520.0, 160.0])
|
.default_size([520.0, 160.0])
|
||||||
.resizable(true)
|
.resizable(true)
|
||||||
.show(&ctx, |ui| {
|
.show(&ctx, |ui| {
|
||||||
ui.label("Project folder");
|
ui.label("Project folder");
|
||||||
|
ui.horizontal(|ui| {
|
||||||
ui.text_edit_singleline(&mut self.new_project_path);
|
ui.text_edit_singleline(&mut self.new_project_path);
|
||||||
|
if ui
|
||||||
|
.add_enabled(!picking, egui::Button::new("Browse…"))
|
||||||
|
.on_hover_text(
|
||||||
|
"Pick the project folder with the system dialog \
|
||||||
|
(it can create a new folder too)",
|
||||||
|
)
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
browse_now = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
ui.label("Display name");
|
ui.label("Display name");
|
||||||
ui.text_edit_singleline(&mut self.new_project_name);
|
ui.text_edit_singleline(&mut self.new_project_name);
|
||||||
ui.add_space(6.0);
|
ui.add_space(6.0);
|
||||||
@@ -1887,6 +2017,12 @@ impl Shell {
|
|||||||
project.oxide.",
|
project.oxide.",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
if browse_now {
|
||||||
|
let start = Some(PathBuf::from(self.new_project_path.trim()))
|
||||||
|
.filter(|p| p.is_dir())
|
||||||
|
.or_else(|| std::env::var_os("HOME").map(PathBuf::from));
|
||||||
|
self.launch_folder_pick(FolderPickTarget::NewProject, start);
|
||||||
|
}
|
||||||
if create_now {
|
if create_now {
|
||||||
let path = PathBuf::from(self.new_project_path.trim());
|
let path = PathBuf::from(self.new_project_path.trim());
|
||||||
let name = self.new_project_name.trim().to_owned();
|
let name = self.new_project_name.trim().to_owned();
|
||||||
@@ -1906,19 +2042,31 @@ impl Shell {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// In-app Open Project dialog: one path field, Open / Cancel.
|
/// In-app Open Project dialog: a path field with a native Browse… picker,
|
||||||
|
/// Open / Cancel.
|
||||||
fn open_project_window(&mut self, ui: &mut egui::Ui) {
|
fn open_project_window(&mut self, ui: &mut egui::Ui) {
|
||||||
let ctx = ui.ctx().clone();
|
let ctx = ui.ctx().clone();
|
||||||
let mut open = self.show_open_project;
|
let mut open = self.show_open_project;
|
||||||
let mut open_now = false;
|
let mut open_now = false;
|
||||||
let mut cancel_now = false;
|
let mut cancel_now = false;
|
||||||
|
let mut browse_now = false;
|
||||||
|
let picking = self.folder_pick.is_some();
|
||||||
egui::Window::new("Open Project")
|
egui::Window::new("Open Project")
|
||||||
.open(&mut open)
|
.open(&mut open)
|
||||||
.default_size([520.0, 140.0])
|
.default_size([520.0, 140.0])
|
||||||
.resizable(true)
|
.resizable(true)
|
||||||
.show(&ctx, |ui| {
|
.show(&ctx, |ui| {
|
||||||
ui.label("Project folder (or path to project.oxide)");
|
ui.label("Project folder (or path to project.oxide)");
|
||||||
|
ui.horizontal(|ui| {
|
||||||
ui.text_edit_singleline(&mut self.open_project_path);
|
ui.text_edit_singleline(&mut self.open_project_path);
|
||||||
|
if ui
|
||||||
|
.add_enabled(!picking, egui::Button::new("Browse…"))
|
||||||
|
.on_hover_text("Pick the project folder with the system dialog")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
browse_now = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
ui.add_space(6.0);
|
ui.add_space(6.0);
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
let valid = !self.open_project_path.trim().is_empty();
|
let valid = !self.open_project_path.trim().is_empty();
|
||||||
@@ -1930,6 +2078,16 @@ impl Shell {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
if browse_now {
|
||||||
|
let start = Some(PathBuf::from(self.open_project_path.trim()))
|
||||||
|
.filter(|p| p.is_dir())
|
||||||
|
.or_else(|| {
|
||||||
|
let first = self.state.recent.entries().first()?;
|
||||||
|
Some(first.parent()?.to_path_buf())
|
||||||
|
})
|
||||||
|
.or_else(|| std::env::var_os("HOME").map(PathBuf::from));
|
||||||
|
self.launch_folder_pick(FolderPickTarget::OpenProject, start);
|
||||||
|
}
|
||||||
if open_now {
|
if open_now {
|
||||||
let path = PathBuf::from(self.open_project_path.trim());
|
let path = PathBuf::from(self.open_project_path.trim());
|
||||||
match self.open_project(path) {
|
match self.open_project(path) {
|
||||||
@@ -2154,6 +2312,12 @@ struct ShellTabViewer<'a> {
|
|||||||
extensions: &'a mut EditorExtensions,
|
extensions: &'a mut EditorExtensions,
|
||||||
pending: &'a mut Vec<PendingAction>,
|
pending: &'a mut Vec<PendingAction>,
|
||||||
rename_buf: &'a mut String,
|
rename_buf: &'a mut String,
|
||||||
|
/// The Project panel's file-explorer state. Borrowed from
|
||||||
|
/// [`Shell::explorer`].
|
||||||
|
explorer: &'a mut crate::explorer::ExplorerState,
|
||||||
|
/// The Script inspector's "New Script" name field. Borrowed from
|
||||||
|
/// [`Shell::new_script_name`].
|
||||||
|
new_script_name: &'a mut String,
|
||||||
/// The Console command-input buffer (the terminal prompt).
|
/// The Console command-input buffer (the terminal prompt).
|
||||||
terminal_input: &'a mut String,
|
terminal_input: &'a mut String,
|
||||||
rot_euler: &'a mut Vec3,
|
rot_euler: &'a mut Vec3,
|
||||||
@@ -3364,6 +3528,21 @@ impl<'a> ShellTabViewer<'a> {
|
|||||||
edits.push((comp.name, row.info.name, new_ron));
|
edits.push((comp.name, row.info.name, new_ron));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Stage-10 UX: author and open scripts without leaving the
|
||||||
|
// editor. The assignment goes through `edits` → SetFieldCmd
|
||||||
|
// like any field change, so it is undoable (the created file
|
||||||
|
// itself stays — harmless).
|
||||||
|
if comp.name == "Script" {
|
||||||
|
let source = comp
|
||||||
|
.fields
|
||||||
|
.iter()
|
||||||
|
.find(|r| r.info.name == "source")
|
||||||
|
.and_then(|r| ron::from_str::<Option<AssetUid>>(&r.value).ok())
|
||||||
|
.flatten();
|
||||||
|
if let Some(ron) = self.new_script_row(ui, source) {
|
||||||
|
edits.push((comp.name, "source", ron));
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3409,6 +3588,126 @@ impl<'a> ShellTabViewer<'a> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The script-tools row at the bottom of a `Script` component's section:
|
||||||
|
/// a name field + "New Script" button that writes a `.rhai` template into
|
||||||
|
/// `assets/scripts/`, registers it in the asset database, and returns the
|
||||||
|
/// RON for the component's `source` field — the caller routes it through
|
||||||
|
/// the normal edit path so the assignment is undoable (undo detaches the
|
||||||
|
/// script; the created file stays, which is harmless) — plus an "Edit"
|
||||||
|
/// button opening the currently assigned script (`source`) in the user's
|
||||||
|
/// editor. Disabled until a project is open. Errors go to the log, i.e.
|
||||||
|
/// the Console panel.
|
||||||
|
fn new_script_row(&mut self, ui: &mut egui::Ui, source: Option<AssetUid>) -> Option<String> {
|
||||||
|
let mut created = None;
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let open = self.state.asset_db.is_some();
|
||||||
|
ui.add_enabled_ui(open, |ui| {
|
||||||
|
let name = ui.add(
|
||||||
|
egui::TextEdit::singleline(self.new_script_name)
|
||||||
|
.hint_text("new_script")
|
||||||
|
.desired_width(120.0),
|
||||||
|
);
|
||||||
|
let submitted = name.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
|
||||||
|
let clicked = ui
|
||||||
|
.small_button("➕ New Script")
|
||||||
|
.on_hover_text(
|
||||||
|
"Create a .rhai file from the template in assets/scripts/ \
|
||||||
|
and assign it to this component",
|
||||||
|
)
|
||||||
|
.clicked();
|
||||||
|
if clicked || submitted {
|
||||||
|
let db = self
|
||||||
|
.state
|
||||||
|
.asset_db
|
||||||
|
.as_mut()
|
||||||
|
.expect("row is enabled only with a project open");
|
||||||
|
match crate::assets::create_script_file(&db.assets_dir(), self.new_script_name)
|
||||||
|
{
|
||||||
|
Ok(rel) => {
|
||||||
|
let uid = db.register(&rel);
|
||||||
|
if let Err(err) = db.save() {
|
||||||
|
log::warn!("could not write asset manifest: {err}");
|
||||||
|
}
|
||||||
|
log::info!("created {rel}");
|
||||||
|
self.new_script_name.clear();
|
||||||
|
created = ron::to_string(&Some(uid)).ok();
|
||||||
|
}
|
||||||
|
Err(err) => log::warn!("could not create script: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let edit = ui
|
||||||
|
.add_enabled(source.is_some(), egui::Button::new("✏ Edit").small())
|
||||||
|
.on_hover_text(
|
||||||
|
"Open the assigned script in your editor (External Editor \
|
||||||
|
preference, else $VISUAL/$EDITOR in a Terminal tab, else \
|
||||||
|
xdg-open). Saved edits live-reload.",
|
||||||
|
);
|
||||||
|
if edit.clicked() {
|
||||||
|
if let Some(uid) = source {
|
||||||
|
self.open_script_in_editor(uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if !open {
|
||||||
|
ui.label(egui::RichText::new("(open a project to create scripts)").weak());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
created
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the script asset `uid` in the user's editor. Resolution order:
|
||||||
|
///
|
||||||
|
/// 1. the **External Editor** preference command, spawned detached as
|
||||||
|
/// `<command> <file>`;
|
||||||
|
/// 2. `$VISUAL` / `$EDITOR`, run in a new **Terminal-panel tab** (so TUI
|
||||||
|
/// editors like vim/nano work in-editor);
|
||||||
|
/// 3. `xdg-open` (the desktop's default handler).
|
||||||
|
///
|
||||||
|
/// Whichever way, saved edits flow back through the file watcher's live
|
||||||
|
/// reload — including into a playing scene.
|
||||||
|
fn open_script_in_editor(&mut self, uid: AssetUid) {
|
||||||
|
let Some(abs) = self
|
||||||
|
.state
|
||||||
|
.asset_db
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|db| db.absolute_path(uid))
|
||||||
|
else {
|
||||||
|
log::warn!("script asset {uid:?} has no file to open");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let configured = self
|
||||||
|
.state
|
||||||
|
.settings
|
||||||
|
.get::<ExternalEditorPrefs>(EXTERNAL_EDITOR_SECTION)
|
||||||
|
.map(|p| p.command.trim().to_owned())
|
||||||
|
.unwrap_or_default();
|
||||||
|
if !configured.is_empty() {
|
||||||
|
spawn_detached(&configured, &abs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let not_blank = |v: String| (!v.trim().is_empty()).then_some(v);
|
||||||
|
let terminal_editor = std::env::var("VISUAL")
|
||||||
|
.ok()
|
||||||
|
.and_then(not_blank)
|
||||||
|
.or_else(|| std::env::var("EDITOR").ok().and_then(not_blank));
|
||||||
|
match terminal_editor {
|
||||||
|
Some(editor) => {
|
||||||
|
let leaf = abs
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|| "script".to_owned());
|
||||||
|
// $EDITOR may carry flags — whitespace-split it like the
|
||||||
|
// configured command, then append the file path.
|
||||||
|
let mut parts = editor.split_whitespace().map(str::to_owned);
|
||||||
|
let program = parts.next().unwrap_or_else(|| "vi".to_owned());
|
||||||
|
let owned: Vec<String> = parts.chain([abs.display().to_string()]).collect();
|
||||||
|
let args: Vec<&str> = owned.iter().map(String::as_str).collect();
|
||||||
|
self.launch_terminal(&format!("edit {leaf}"), &program, &args);
|
||||||
|
}
|
||||||
|
None => spawn_detached("xdg-open", &abs),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Renders one reflected field as a typed widget chosen from its
|
/// Renders one reflected field as a typed widget chosen from its
|
||||||
/// `type_name`, returning the field's new RON if the user changed it.
|
/// `type_name`, returning the field's new RON if the user changed it.
|
||||||
/// Unknown types fall back to an editable RON text box, so the inspector is
|
/// Unknown types fall back to an editable RON text box, so the inspector is
|
||||||
@@ -3705,10 +4004,11 @@ impl<'a> ShellTabViewer<'a> {
|
|||||||
ui.monospace(project.root().display().to_string());
|
ui.monospace(project.root().display().to_string());
|
||||||
ui.separator();
|
ui.separator();
|
||||||
|
|
||||||
// Asset browser: typed folders grouped by kind, listed from the
|
// File explorer over assets/ (Stage-10 editor-UX): breadcrumbs,
|
||||||
// database (the source of truth for what the picker can reference).
|
// folder navigation, rename/delete/move, and OS drag-in import — all
|
||||||
// "Importing" an asset is just dropping the file into the right folder;
|
// over the AssetDatabase so every operation keeps uids (and saved
|
||||||
// the file watcher rescans, or the user can rescan on demand.
|
// AssetRefs) intact. Importing also still works by dropping a file
|
||||||
|
// into assets/ on disk; the watcher or Rescan registers it.
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.label("Assets");
|
ui.label("Assets");
|
||||||
if ui
|
if ui
|
||||||
@@ -3721,13 +4021,16 @@ impl<'a> ShellTabViewer<'a> {
|
|||||||
let _ = db.save();
|
let _ = db.save();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if ui
|
||||||
|
.small_button("➕ New Folder")
|
||||||
|
.on_hover_text("Create a folder in the current directory")
|
||||||
|
.clicked()
|
||||||
|
{
|
||||||
|
self.explorer.new_folder = Some(String::new());
|
||||||
|
self.explorer.focus_field = true;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
match &self.state.asset_db {
|
self.assets_explorer(ui);
|
||||||
Some(db) => asset_browser(ui, db),
|
|
||||||
None => {
|
|
||||||
ui.weak("(asset database unavailable)");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.separator();
|
ui.separator();
|
||||||
let project = self.state.project.as_ref().unwrap();
|
let project = self.state.project.as_ref().unwrap();
|
||||||
@@ -3735,6 +4038,325 @@ impl<'a> ShellTabViewer<'a> {
|
|||||||
show_project_tree(ui, "scripts/", project.scripts_dir());
|
show_project_tree(ui, "scripts/", project.scripts_dir());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The Unity-style file explorer over `assets/`: breadcrumbs, folder
|
||||||
|
/// navigation (double-click), inline rename / new-folder rows, per-row
|
||||||
|
/// context menus, drag-to-move between folders, and OS drag-in import
|
||||||
|
/// into the current folder. All behavior lives in [`crate::explorer`];
|
||||||
|
/// this only renders it and collects intents (applied after the render
|
||||||
|
/// pass, so no closure ever borrows `self.state` and the explorer
|
||||||
|
/// buffers at once). File operations act immediately and bypass the undo
|
||||||
|
/// stack — like the hierarchy's structural edits — but every move goes
|
||||||
|
/// through the database's uid-preserving ops, so saved `AssetRef`s keep
|
||||||
|
/// resolving.
|
||||||
|
fn assets_explorer(&mut self, ui: &mut egui::Ui) {
|
||||||
|
use crate::explorer::{self as exp, Entry};
|
||||||
|
|
||||||
|
let Some(db) = &self.state.asset_db else {
|
||||||
|
ui.weak("(asset database unavailable)");
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let cwd = self.explorer.cwd.clone();
|
||||||
|
let entries = exp::list_dir(db, &cwd);
|
||||||
|
let crumbs = exp::breadcrumbs(&cwd);
|
||||||
|
|
||||||
|
// Intents collected during render, applied afterwards.
|
||||||
|
let mut nav: Option<String> = None;
|
||||||
|
let mut open: Option<Entry> = None;
|
||||||
|
let mut start_rename: Option<Entry> = None;
|
||||||
|
let mut delete: Option<Entry> = None;
|
||||||
|
// (what to move: rel + is_dir, destination folder)
|
||||||
|
let mut moved: Option<(String, bool, String)> = None;
|
||||||
|
let mut commit_rename = false;
|
||||||
|
let mut cancel_rename = false;
|
||||||
|
let mut commit_new_folder = false;
|
||||||
|
let mut cancel_new_folder = false;
|
||||||
|
|
||||||
|
// Breadcrumbs: assets / textures / env — click a segment to jump.
|
||||||
|
ui.horizontal_wrapped(|ui| {
|
||||||
|
ui.spacing_mut().item_spacing.x = 4.0;
|
||||||
|
for (i, (label, target)) in crumbs.iter().enumerate() {
|
||||||
|
if i > 0 {
|
||||||
|
ui.weak("/");
|
||||||
|
}
|
||||||
|
let here = *target == cwd;
|
||||||
|
if ui.selectable_label(here, label).clicked() && !here {
|
||||||
|
nav = Some(target.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag state shared by every drop target this frame.
|
||||||
|
let dragged = egui::DragAndDrop::payload::<ExplorerDragPayload>(ui.ctx());
|
||||||
|
let pointer = ui.ctx().pointer_interact_pos();
|
||||||
|
let released = ui.input(|i| i.pointer.any_released());
|
||||||
|
let accent = ui.visuals().selection.bg_fill;
|
||||||
|
let escape = ui.input(|i| i.key_pressed(egui::Key::Escape));
|
||||||
|
let focus_now = std::mem::take(&mut self.explorer.focus_field);
|
||||||
|
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.id_salt("oxide.explorer")
|
||||||
|
.auto_shrink([false, true])
|
||||||
|
.show(ui, |ui| {
|
||||||
|
// Inline "New Folder" row.
|
||||||
|
if let Some(buf) = &mut self.explorer.new_folder {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("▸");
|
||||||
|
let resp = ui.add(
|
||||||
|
egui::TextEdit::singleline(buf)
|
||||||
|
.hint_text("folder name")
|
||||||
|
.desired_width(160.0),
|
||||||
|
);
|
||||||
|
if focus_now {
|
||||||
|
resp.request_focus();
|
||||||
|
}
|
||||||
|
if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||||
|
commit_new_folder = true;
|
||||||
|
}
|
||||||
|
if ui.small_button("Create").clicked() {
|
||||||
|
commit_new_folder = true;
|
||||||
|
}
|
||||||
|
if escape {
|
||||||
|
cancel_new_folder = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ".." row: double-click to go up; also a drop target that
|
||||||
|
// moves items into the parent folder.
|
||||||
|
if !cwd.is_empty() {
|
||||||
|
let up = exp::parent_of(&cwd);
|
||||||
|
let resp = ui
|
||||||
|
.add(
|
||||||
|
egui::Label::new("▸ ..")
|
||||||
|
.selectable(false)
|
||||||
|
.sense(egui::Sense::click()),
|
||||||
|
)
|
||||||
|
.on_hover_text("Double-click to go up — drop items here to move them up");
|
||||||
|
if resp.double_clicked() {
|
||||||
|
nav = Some(up.clone());
|
||||||
|
}
|
||||||
|
if let (Some(payload), Some(pos)) = (&dragged, pointer) {
|
||||||
|
if resp.rect.contains(pos) {
|
||||||
|
stroke_rect(
|
||||||
|
ui.painter(),
|
||||||
|
resp.rect,
|
||||||
|
egui::Stroke::new(1.5_f32, accent),
|
||||||
|
);
|
||||||
|
if released {
|
||||||
|
moved = Some((payload.rel.clone(), payload.is_dir, up.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if entries.is_empty() && self.explorer.new_folder.is_none() {
|
||||||
|
ui.weak("(empty — drag files in from your file manager to import)");
|
||||||
|
}
|
||||||
|
|
||||||
|
for entry in &entries {
|
||||||
|
// An entry mid-rename renders as a text field instead.
|
||||||
|
let renaming = self
|
||||||
|
.explorer
|
||||||
|
.rename
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|r| r.rel == entry.rel);
|
||||||
|
if renaming {
|
||||||
|
let buf = &mut self.explorer.rename.as_mut().expect("checked").buf;
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let resp = ui.add(egui::TextEdit::singleline(buf).desired_width(180.0));
|
||||||
|
if focus_now {
|
||||||
|
resp.request_focus();
|
||||||
|
}
|
||||||
|
if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
|
||||||
|
commit_rename = true;
|
||||||
|
}
|
||||||
|
if ui.small_button("Rename").clicked() {
|
||||||
|
commit_rename = true;
|
||||||
|
}
|
||||||
|
if escape {
|
||||||
|
cancel_rename = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let display = if entry.is_dir {
|
||||||
|
format!("▸ {}/", entry.name)
|
||||||
|
} else {
|
||||||
|
entry.name.clone()
|
||||||
|
};
|
||||||
|
let hover = match (entry.is_dir, entry.kind) {
|
||||||
|
(true, _) => format!(
|
||||||
|
"{}/\ndouble-click to open · drag to move · right-click for actions",
|
||||||
|
entry.rel
|
||||||
|
),
|
||||||
|
(false, Some(kind)) => format!(
|
||||||
|
"{}\n{kind:?} asset · double-click to open · drag to move",
|
||||||
|
entry.rel
|
||||||
|
),
|
||||||
|
(false, None) => {
|
||||||
|
format!("{}\nunregistered file · drag to move", entry.rel)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// One widget senses click + drag (same pattern as the
|
||||||
|
// hierarchy rows): still press = click, press+move = drag.
|
||||||
|
let resp = ui
|
||||||
|
.add(
|
||||||
|
egui::Label::new(display)
|
||||||
|
.selectable(false)
|
||||||
|
.sense(egui::Sense::click_and_drag()),
|
||||||
|
)
|
||||||
|
.on_hover_text(hover);
|
||||||
|
if resp.drag_started() {
|
||||||
|
egui::DragAndDrop::set_payload(
|
||||||
|
ui.ctx(),
|
||||||
|
ExplorerDragPayload {
|
||||||
|
rel: entry.rel.clone(),
|
||||||
|
is_dir: entry.is_dir,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if resp.dragged() {
|
||||||
|
ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing);
|
||||||
|
}
|
||||||
|
if resp.double_clicked() {
|
||||||
|
if entry.is_dir {
|
||||||
|
nav = Some(entry.rel.clone());
|
||||||
|
} else {
|
||||||
|
open = Some(entry.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Folder rows accept drops (not from themselves).
|
||||||
|
if entry.is_dir {
|
||||||
|
if let (Some(payload), Some(pos)) = (&dragged, pointer) {
|
||||||
|
if payload.rel != entry.rel && resp.rect.contains(pos) {
|
||||||
|
stroke_rect(
|
||||||
|
ui.painter(),
|
||||||
|
resp.rect,
|
||||||
|
egui::Stroke::new(1.5_f32, accent),
|
||||||
|
);
|
||||||
|
if released {
|
||||||
|
moved = Some((
|
||||||
|
payload.rel.clone(),
|
||||||
|
payload.is_dir,
|
||||||
|
entry.rel.clone(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp.context_menu(|ui| {
|
||||||
|
if ui.button("Open").clicked() {
|
||||||
|
if entry.is_dir {
|
||||||
|
nav = Some(entry.rel.clone());
|
||||||
|
} else {
|
||||||
|
open = Some(entry.clone());
|
||||||
|
}
|
||||||
|
ui.close();
|
||||||
|
}
|
||||||
|
if ui.button("✏ Rename").clicked() {
|
||||||
|
start_rename = Some(entry.clone());
|
||||||
|
ui.close();
|
||||||
|
}
|
||||||
|
ui.separator();
|
||||||
|
if ui.button("🗑 Delete").clicked() {
|
||||||
|
delete = Some(entry.clone());
|
||||||
|
ui.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// OS drag-in import: while files hover the window, hint; when they
|
||||||
|
// drop (and the Project panel is showing), copy + register them into
|
||||||
|
// the current folder.
|
||||||
|
if ui.input(|i| !i.raw.hovered_files.is_empty()) {
|
||||||
|
ui.colored_label(accent, format!("Drop to import into assets/{cwd}"));
|
||||||
|
}
|
||||||
|
let dropped: Vec<PathBuf> = ui.input(|i| {
|
||||||
|
i.raw
|
||||||
|
.dropped_files
|
||||||
|
.iter()
|
||||||
|
.filter_map(|f| f.path.clone())
|
||||||
|
.collect()
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- apply the collected intents ---------------------------------
|
||||||
|
if cancel_new_folder {
|
||||||
|
self.explorer.new_folder = None;
|
||||||
|
} else if commit_new_folder {
|
||||||
|
let wanted = self.explorer.new_folder.take().unwrap_or_default();
|
||||||
|
if let Some(db) = &self.state.asset_db {
|
||||||
|
match exp::create_folder(db, &cwd, wanted.trim()) {
|
||||||
|
Ok(rel) => log::info!("created folder assets/{rel}"),
|
||||||
|
Err(err) => log::warn!("could not create folder: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cancel_rename {
|
||||||
|
self.explorer.rename = None;
|
||||||
|
} else if commit_rename {
|
||||||
|
if let Some(edit) = self.explorer.rename.take() {
|
||||||
|
if let Some(db) = self.state.asset_db.as_mut() {
|
||||||
|
match exp::rename_entry(db, &edit.rel, edit.is_dir, edit.buf.trim()) {
|
||||||
|
Ok(new_rel) => log::info!("renamed {} -> {new_rel}", edit.rel),
|
||||||
|
Err(err) => log::warn!("could not rename {}: {err}", edit.rel),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(entry) = start_rename {
|
||||||
|
self.explorer.rename = Some(crate::explorer::RenameEdit {
|
||||||
|
rel: entry.rel,
|
||||||
|
is_dir: entry.is_dir,
|
||||||
|
buf: entry.name,
|
||||||
|
});
|
||||||
|
self.explorer.focus_field = true;
|
||||||
|
}
|
||||||
|
if let Some((rel, is_dir, dest)) = moved {
|
||||||
|
if let Some(db) = self.state.asset_db.as_mut() {
|
||||||
|
match exp::move_entry(db, &rel, is_dir, &dest) {
|
||||||
|
Ok(new_rel) => log::info!("moved {rel} -> {new_rel}"),
|
||||||
|
Err(err) => log::warn!("could not move {rel}: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(entry) = delete {
|
||||||
|
if let Some(db) = self.state.asset_db.as_mut() {
|
||||||
|
match exp::delete_entry(db, &entry) {
|
||||||
|
Ok(()) => log::info!("deleted {}", entry.rel),
|
||||||
|
Err(err) => log::warn!("could not delete {}: {err}", entry.rel),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(entry) = open {
|
||||||
|
if entry.kind == Some(AssetKind::Script) {
|
||||||
|
if let Some(uid) = entry.uid {
|
||||||
|
self.open_script_in_editor(uid);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
let abs = self.state.asset_db.as_ref().map(|db| {
|
||||||
|
db.assets_dir()
|
||||||
|
.join(entry.rel.replace('/', std::path::MAIN_SEPARATOR_STR))
|
||||||
|
});
|
||||||
|
if let Some(abs) = abs {
|
||||||
|
spawn_detached("xdg-open", &abs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !dropped.is_empty() {
|
||||||
|
if let Some(db) = self.state.asset_db.as_mut() {
|
||||||
|
let n = exp::import_files(db, &cwd, &dropped);
|
||||||
|
if n > 0 {
|
||||||
|
log::info!("imported {n} file(s) into assets/{cwd}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(target) = nav {
|
||||||
|
self.explorer.navigate(target);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The UI canvas: edit a [`UiPanel`] document — widget tree, live preview,
|
/// The UI canvas: edit a [`UiPanel`] document — widget tree, live preview,
|
||||||
/// and a property panel (including the font-asset picker) — saved as a
|
/// and a property panel (including the font-asset picker) — saved as a
|
||||||
/// `ui/` asset. Structural and property edits route through
|
/// `ui/` asset. Structural and property edits route through
|
||||||
@@ -4315,6 +4937,29 @@ impl<'a> ShellTabViewer<'a> {
|
|||||||
|
|
||||||
// ---- helpers -----------------------------------------------------------
|
// ---- helpers -----------------------------------------------------------
|
||||||
|
|
||||||
|
/// Spawns `command` (whitespace-split, so it may carry flags) with `file`
|
||||||
|
/// appended as the final argument, detached from the editor; a reaper thread
|
||||||
|
/// waits on the child so it never lingers as a zombie. Failures are logged
|
||||||
|
/// (→ the Console), never fatal.
|
||||||
|
fn spawn_detached(command: &str, file: &std::path::Path) {
|
||||||
|
let mut parts = command.split_whitespace();
|
||||||
|
let Some(program) = parts.next() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
match std::process::Command::new(program)
|
||||||
|
.args(parts)
|
||||||
|
.arg(file)
|
||||||
|
.spawn()
|
||||||
|
{
|
||||||
|
Ok(mut child) => {
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = child.wait();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(err) => log::warn!("could not launch {command}: {err}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns whether the rendered control changed any axis this frame.
|
/// Returns whether the rendered control changed any axis this frame.
|
||||||
/// Projects a world-space point through `view_proj` to screen pixels
|
/// Projects a world-space point through `view_proj` to screen pixels
|
||||||
/// inside `tab_rect` (egui logical points). Returns `None` when the point
|
/// inside `tab_rect` (egui logical points). Returns `None` when the point
|
||||||
@@ -4719,10 +5364,6 @@ fn snapshot_node(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renders the typed asset folders (one collapsing section per [`AssetKind`])
|
|
||||||
/// from the database, listing each asset by its name. Drop a file into the
|
|
||||||
/// matching folder on disk to import it; the watcher (or the Rescan button)
|
|
||||||
/// registers it.
|
|
||||||
/// The colour for a console log line of the given severity: errors red, warnings
|
/// The colour for a console log line of the given severity: errors red, warnings
|
||||||
/// amber, info the normal text colour, debug/trace dimmed.
|
/// amber, info the normal text colour, debug/trace dimmed.
|
||||||
fn level_color(ui: &egui::Ui, level: log::Level) -> egui::Color32 {
|
fn level_color(ui: &egui::Ui, level: log::Level) -> egui::Color32 {
|
||||||
@@ -4793,26 +5434,6 @@ fn build_terminal_job(
|
|||||||
job
|
job
|
||||||
}
|
}
|
||||||
|
|
||||||
fn asset_browser(ui: &mut egui::Ui, db: &AssetDatabase) {
|
|
||||||
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);
|
|
||||||
ui.label(leaf).on_hover_text(&entry.path);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A short label for a widget in the tree / property header: its id if set,
|
/// A short label for a widget in the tree / property header: its id if set,
|
||||||
/// else its kind, with the root marked.
|
/// else its kind, with the root marked.
|
||||||
fn widget_label(widget: &Widget, path: &WidgetPath) -> String {
|
fn widget_label(widget: &Widget, path: &WidgetPath) -> String {
|
||||||
|
|||||||
@@ -209,6 +209,20 @@ impl Default for UiDoc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The settings section holding [`ExternalEditorPrefs`].
|
||||||
|
pub const EXTERNAL_EDITOR_SECTION: &str = "editor.external_editor";
|
||||||
|
|
||||||
|
/// Preferences for opening a script (or other text asset) in an editor —
|
||||||
|
/// registered as the [`EXTERNAL_EDITOR_SECTION`] settings section, editable in
|
||||||
|
/// Preferences, persisted to `~/.config/oxide/editor.ron` like the bindings.
|
||||||
|
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct ExternalEditorPrefs {
|
||||||
|
/// Command to launch, invoked as `<command> <file>` (whitespace-split; may
|
||||||
|
/// carry its own flags, e.g. `"code -g"`). **Empty (the default) = auto**:
|
||||||
|
/// run `$VISUAL`/`$EDITOR` in an editor Terminal tab, else `xdg-open`.
|
||||||
|
pub command: String,
|
||||||
|
}
|
||||||
|
|
||||||
impl EditorState {
|
impl EditorState {
|
||||||
/// A blank state with an empty scene, no open project, and the editor's
|
/// A blank state with an empty scene, no open project, and the editor's
|
||||||
/// default action bindings registered (`F` toggle, WASD/QE move, Shift
|
/// default action bindings registered (`F` toggle, WASD/QE move, Shift
|
||||||
@@ -224,6 +238,7 @@ impl EditorState {
|
|||||||
bindings::register_defaults(&mut actions);
|
bindings::register_defaults(&mut actions);
|
||||||
let mut settings = Settings::new();
|
let mut settings = Settings::new();
|
||||||
settings.register::<ActionOverrides>(bindings::SETTINGS_SECTION);
|
settings.register::<ActionOverrides>(bindings::SETTINGS_SECTION);
|
||||||
|
settings.register::<ExternalEditorPrefs>(EXTERNAL_EDITOR_SECTION);
|
||||||
let mut registry = TypeRegistry::new();
|
let mut registry = TypeRegistry::new();
|
||||||
register_builtin_types(&mut registry);
|
register_builtin_types(&mut registry);
|
||||||
// Seed a small, generally-useful set of named layers (besides the
|
// Seed a small, generally-useful set of named layers (besides the
|
||||||
@@ -590,4 +605,17 @@ mod tests {
|
|||||||
.get::<oxide_physics::RigidBody>(restored)
|
.get::<oxide_physics::RigidBody>(restored)
|
||||||
.is_some());
|
.is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn external_editor_section_is_registered_and_defaults_to_auto() {
|
||||||
|
let state = EditorState::new();
|
||||||
|
let prefs = state
|
||||||
|
.settings
|
||||||
|
.get::<ExternalEditorPrefs>(EXTERNAL_EDITOR_SECTION)
|
||||||
|
.expect("external-editor settings section must be registered");
|
||||||
|
assert!(
|
||||||
|
prefs.command.is_empty(),
|
||||||
|
"default is empty = auto ($VISUAL/$EDITOR terminal tab, else xdg-open)"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -440,18 +440,21 @@ impl AssetDatabase {
|
|||||||
uid
|
uid
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Scans the typed asset folders under `assets/` and reconciles the
|
/// Scans everything under `assets/` and reconciles the database with what
|
||||||
/// database with what is on disk: existing files keep their uid, new files
|
/// is on disk: existing files keep their uid, new files are
|
||||||
/// are [registered](Self::register), and entries whose files no longer exist
|
/// [registered](Self::register), and entries whose files no longer exist
|
||||||
/// are dropped. Returns the number of newly registered assets.
|
/// are dropped. Returns the number of newly registered assets.
|
||||||
///
|
///
|
||||||
|
/// The walk covers the **whole** assets tree, not just the typed folders —
|
||||||
|
/// the editor's file explorer lets a project organise assets in arbitrary
|
||||||
|
/// folders, and a scan must never prune them. Files outside a typed folder
|
||||||
|
/// classify by extension (else [`Other`](AssetKind::Other)), as always.
|
||||||
|
///
|
||||||
/// Call [`save`](Self::save) afterwards to persist any new uids.
|
/// Call [`save`](Self::save) afterwards to persist any new uids.
|
||||||
pub fn scan(&mut self) -> usize {
|
pub fn scan(&mut self) -> usize {
|
||||||
let assets_dir = self.assets_dir();
|
let assets_dir = self.assets_dir();
|
||||||
let mut found: Vec<String> = Vec::new();
|
let mut found: Vec<String> = Vec::new();
|
||||||
for kind in AssetKind::TYPED {
|
collect_files(&assets_dir, &assets_dir, &mut found);
|
||||||
collect_files(&assets_dir.join(kind.folder()), &assets_dir, &mut found);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drop entries whose backing file disappeared.
|
// Drop entries whose backing file disappeared.
|
||||||
let present: std::collections::HashSet<&String> = found.iter().collect();
|
let present: std::collections::HashSet<&String> = found.iter().collect();
|
||||||
@@ -811,12 +814,27 @@ mod tests {
|
|||||||
assert_eq!(db.len(), 3);
|
assert_eq!(db.len(), 3);
|
||||||
assert_eq!(db.entries_of_kind(AssetKind::Font).count(), 1);
|
assert_eq!(db.entries_of_kind(AssetKind::Font).count(), 1);
|
||||||
|
|
||||||
|
// The walk covers the whole tree: a custom folder and a loose root
|
||||||
|
// file both register (kind from extension, else Other) and survive
|
||||||
|
// subsequent scans.
|
||||||
|
touch_asset(&root, "props/crate.glb");
|
||||||
|
touch_asset(&root, "notes.md");
|
||||||
|
assert_eq!(db.scan(), 2);
|
||||||
|
let crate_uid = db.uid_of("props/crate.glb").unwrap();
|
||||||
|
assert_eq!(db.entry(crate_uid).unwrap().kind, AssetKind::Model);
|
||||||
|
assert_eq!(
|
||||||
|
db.entry(db.uid_of("notes.md").unwrap()).unwrap().kind,
|
||||||
|
AssetKind::Other
|
||||||
|
);
|
||||||
|
assert_eq!(db.scan(), 0);
|
||||||
|
assert_eq!(db.uid_of("props/crate.glb"), Some(crate_uid));
|
||||||
|
|
||||||
// Remove one file and rescan: it is pruned, the rest keep their uids.
|
// Remove one file and rescan: it is pruned, the rest keep their uids.
|
||||||
let font_uid = db.uid_of("fonts/Inter.ttf").unwrap();
|
let font_uid = db.uid_of("fonts/Inter.ttf").unwrap();
|
||||||
let wall_uid = db.uid_of("textures/wall.png").unwrap();
|
let wall_uid = db.uid_of("textures/wall.png").unwrap();
|
||||||
std::fs::remove_file(root.join(ASSETS_DIR).join("fonts/Inter.ttf")).unwrap();
|
std::fs::remove_file(root.join(ASSETS_DIR).join("fonts/Inter.ttf")).unwrap();
|
||||||
assert_eq!(db.scan(), 0);
|
assert_eq!(db.scan(), 0);
|
||||||
assert_eq!(db.len(), 2);
|
assert_eq!(db.len(), 4, "wall + cube + crate + notes remain");
|
||||||
assert!(db.entry(font_uid).is_none());
|
assert!(db.entry(font_uid).is_none());
|
||||||
assert_eq!(db.uid_of("textures/wall.png"), Some(wall_uid));
|
assert_eq!(db.uid_of("textures/wall.png"), Some(wall_uid));
|
||||||
|
|
||||||
@@ -922,12 +940,11 @@ mod tests {
|
|||||||
db.move_asset(uid, "misc/brick.dat").unwrap();
|
db.move_asset(uid, "misc/brick.dat").unwrap();
|
||||||
assert_eq!(db.entry(uid).unwrap().kind, AssetKind::Other);
|
assert_eq!(db.entry(uid).unwrap().kind, AssetKind::Other);
|
||||||
|
|
||||||
// A rescan does not disturb the moved entry's uid... (misc/ is not a
|
// A rescan (which walks the whole tree) does not disturb the moved
|
||||||
// typed folder, so the entry survives only because scan never saw it —
|
// entry's uid, even outside the typed folders.
|
||||||
// move back first to prove the typed-folder case.)
|
|
||||||
db.move_asset(uid, "textures/brick.png").unwrap();
|
|
||||||
db.scan();
|
db.scan();
|
||||||
assert_eq!(db.uid_of("textures/brick.png"), Some(uid));
|
assert_eq!(db.uid_of("misc/brick.dat"), Some(uid));
|
||||||
|
db.move_asset(uid, "textures/brick.png").unwrap();
|
||||||
|
|
||||||
// No-op and error cases.
|
// No-op and error cases.
|
||||||
db.move_asset(uid, "textures/brick.png").unwrap(); // unchanged path: Ok
|
db.move_asset(uid, "textures/brick.png").unwrap(); // unchanged path: Ok
|
||||||
|
|||||||
Reference in New Issue
Block a user