Skip to main content

khora_editor/
project_vfs.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Project-scoped Virtual File System for the editor.
16//!
17//! Wraps the `khora-io` `AssetService` + `AssetWatcher` together with a
18//! `FileLoader` rooted at `<project>/assets/`. This is the single I/O entry
19//! point used by `scene_io`, the asset browser, and any other editor code
20//! that needs to read or write project content.
21
22use anyhow::{bail, Context, Result};
23use khora_sdk::khora_core::asset::AssetUUID;
24use khora_sdk::khora_core::renderer::api::scene::Mesh;
25use khora_sdk::{
26    AssetChangeEvent, AssetIdRegistry, AssetService, AssetWatcher, AssetWriter, FileLoader,
27    FileSystemResolver, IndexBuilder, MeshDispatcher, MetricsRegistry, SoundData, SymphoniaDecoder,
28};
29use std::{
30    path::{Path, PathBuf},
31    sync::Arc,
32};
33
34/// All project I/O, in one place.
35///
36/// `open` performs a recursive scan of `<root>/assets/`, builds an in-memory
37/// VFS index (UUIDs derived from forward-slash relative paths via
38/// `AssetUUID::new_v5`), constructs the `AssetService` with every default
39/// decoder registered, and arms a recursive filesystem watcher for hot
40/// reload. UUIDs match what a future pack-builder would produce, so the
41/// dev/release transparency promise of the VFS is preserved.
42pub struct ProjectVfs {
43    pub root: PathBuf,
44    pub assets_root: PathBuf,
45    pub asset_service: AssetService,
46    pub watcher: AssetWatcher,
47    file_loader: FileLoader,
48    /// Stable-identity registry (`<root>/.khora/asset-registry.ron`). The editor
49    /// is the sole writer: file operations freeze/rename/remove entries so an
50    /// asset keeps its UUID across renames and every reference keeps resolving.
51    registry: AssetIdRegistry,
52}
53
54impl ProjectVfs {
55    /// Opens (or creates) `<root>/assets/`, builds the VFS, and arms the
56    /// watcher. Tolerates fresh projects whose `assets/` directory hasn't
57    /// been populated yet — the resulting service has zero indexed assets,
58    /// which is exactly what the asset browser will show.
59    pub fn open(root: PathBuf, metrics: Arc<MetricsRegistry>) -> Result<Self> {
60        let assets_root = root.join("assets");
61        std::fs::create_dir_all(&assets_root).with_context(|| {
62            format!(
63                "Failed to ensure project assets directory exists: {}",
64                assets_root.display()
65            )
66        })?;
67
68        // The identity registry lives at the project root (sibling of
69        // `assets/`), so it is never scanned, watched, or packed. Missing file →
70        // empty registry → every path keeps its `new_v5` default.
71        let registry = AssetIdRegistry::load(&root);
72        // Materialize the file on first open so it's visible in the project and
73        // confirms persistence is wired, even before the first rename.
74        if let Err(e) = registry.ensure_file() {
75            log::warn!("Could not create asset identity registry file: {e}");
76        }
77
78        let index_bytes = IndexBuilder::new(&assets_root)
79            .with_registry(&registry)
80            .build_index_bytes()
81            .context("Failed to build initial project asset index")?;
82
83        let file_loader = FileLoader::new(&assets_root);
84        // Note: we hand a *clone* (a fresh FileLoader) to the AssetService so
85        // that we keep our own `file_loader` available to implement
86        // `AssetWriter` for scene saves. They both read/write the same root,
87        // so this is consistent.
88        let io = Box::new(FileLoader::new(&assets_root));
89        let mut asset_service = AssetService::new(&index_bytes, io, metrics, None)
90            .context("Failed to construct AssetService")?;
91
92        // texture + font auto-register via inventory.
93        asset_service.register_inventory_decoders();
94        // audio + mesh are explicitly chosen by the consumer (see
95        // doctrine in decoders/{audio,mesh}/mod.rs).
96        asset_service.register_decoder::<SoundData>("audio", SymphoniaDecoder);
97        // Mesh dispatch: gltf URIs (external `.bin` / texture buffers) are
98        // resolved relative to the project's `assets/` root. Authors place
99        // referenced resources at project-relative paths (e.g.
100        // `meshes/character/diffuse.png`) and reference them with that same
101        // path inside the gltf — this diverges from the strict gltf-spec
102        // "URIs are relative to the gltf file" but matches the rest of the
103        // VFS's path convention. `.glb` and `.obj` files are self-contained
104        // and don't go through the resolver at all.
105        let gltf_resolver = Arc::new(FileSystemResolver::new(&assets_root));
106        asset_service.register_decoder::<Mesh>("mesh", MeshDispatcher::new(gltf_resolver));
107
108        let watcher = AssetWatcher::new(&assets_root)
109            .context("Failed to start asset watcher (filesystem hot reload)")?;
110
111        log::info!(
112            "ProjectVfs opened: {} assets indexed under {}, watcher armed.",
113            asset_service.vfs().asset_count(),
114            assets_root.display()
115        );
116
117        Ok(Self {
118            root,
119            assets_root,
120            asset_service,
121            watcher,
122            file_loader,
123            registry,
124        })
125    }
126
127    /// Re-walks `<root>/assets/` and atomically swaps the VFS index. Called
128    /// after a scene save (so the new file shows up) and from the
129    /// hot-reload pump on Created/Removed events.
130    pub fn rebuild_index(&mut self) -> Result<()> {
131        let bytes = IndexBuilder::new(&self.assets_root)
132            .with_registry(&self.registry)
133            .build_index_bytes()
134            .context("Failed to rebuild project asset index")?;
135        self.asset_service.reindex(&bytes)?;
136        Ok(())
137    }
138
139    /// Reads `<root>/project.json` as untyped JSON. Project metadata isn't an
140    /// asset (lives outside `assets/`) so it deliberately bypasses the VFS.
141    pub fn read_project_json(&self) -> Result<serde_json::Value> {
142        let path = self.root.join("project.json");
143        let text = std::fs::read_to_string(&path)
144            .with_context(|| format!("Failed to read {}", path.display()))?;
145        serde_json::from_str(&text).with_context(|| format!("Failed to parse {}", path.display()))
146    }
147
148    /// Writes `bytes` to `<root>/assets/<rel_path>`. Creates intermediate
149    /// directories as needed. Caller is responsible for calling
150    /// [`Self::rebuild_index`] afterwards if the new path needs to be
151    /// resolvable through the VFS in the same frame.
152    pub fn write_asset(&self, rel_path: &Path, bytes: &[u8]) -> Result<()> {
153        self.file_loader.write_bytes(rel_path, bytes)
154    }
155
156    /// Returns the UUID for a relative-path-with-forward-slashes string,
157    /// regardless of whether the path currently exists on disk. Used by
158    /// `scene_io` to look up scenes by canonical path even before a save
159    /// has triggered a reindex.
160    ///
161    /// This is the raw **path-derived default** and ignores the identity
162    /// registry. Prefer [`Self::resolve_uuid`] whenever the asset may have been
163    /// renamed (frozen identity); use this only for paths that cannot have a
164    /// frozen entry (e.g. a file being written for the very first time).
165    pub fn uuid_for_rel_path(rel_path_fwd_slash: &str) -> AssetUUID {
166        AssetUUID::new_v5(rel_path_fwd_slash)
167    }
168
169    /// Resolves a forward-slash relative path to its UUID through the identity
170    /// registry: the frozen identity if the asset has been renamed, otherwise
171    /// the `new_v5` default. This is the registry-aware counterpart to
172    /// [`Self::uuid_for_rel_path`] and must be used whenever an asset the user
173    /// could have renamed is looked up by path (scene / prefab / material load).
174    pub fn resolve_uuid(&self, rel_path_fwd_slash: &str) -> AssetUUID {
175        self.registry.resolve(rel_path_fwd_slash)
176    }
177
178    /// Absolute path of an asset given its forward-slash relative path.
179    fn abs_of(&self, rel_fwd: &str) -> PathBuf {
180        let mut p = self.assets_root.clone();
181        for seg in rel_fwd.split('/').filter(|s| !s.is_empty()) {
182            p.push(seg);
183        }
184        p
185    }
186
187    /// Creates an (empty) folder under `assets/` at `rel_dir` (forward-slash).
188    /// Empty folders are surfaced by [`Self::list_dirs`] since the VFS itself
189    /// only indexes files.
190    pub fn create_folder(&mut self, rel_dir: &str) -> Result<()> {
191        let abs = self.abs_of(rel_dir);
192        std::fs::create_dir_all(&abs)
193            .with_context(|| format!("Failed to create folder {}", abs.display()))?;
194        Ok(())
195    }
196
197    /// Renames/moves an asset from `old_rel` to `new_rel` (both forward-slash,
198    /// under `assets/`), **freezing its UUID** so every existing reference keeps
199    /// resolving. Fails if the destination already exists. Rebuilds the index.
200    pub fn rename_asset(&mut self, old_rel: &str, new_rel: &str) -> Result<()> {
201        if old_rel == new_rel {
202            return Ok(());
203        }
204        let old_abs = self.abs_of(old_rel);
205        let new_abs = self.abs_of(new_rel);
206        if !old_abs.exists() {
207            bail!("Source asset does not exist: {}", old_abs.display());
208        }
209        if new_abs.exists() {
210            bail!("Destination already exists: {}", new_abs.display());
211        }
212        if let Some(parent) = new_abs.parent() {
213            std::fs::create_dir_all(parent)
214                .with_context(|| format!("Failed to create {}", parent.display()))?;
215        }
216        std::fs::rename(&old_abs, &new_abs).with_context(|| {
217            format!(
218                "Failed to move {} → {}",
219                old_abs.display(),
220                new_abs.display()
221            )
222        })?;
223
224        // Freeze identity across the move, then persist. In-memory update must
225        // precede `rebuild_index` so the new path resolves to the frozen UUID.
226        self.registry.rename(old_rel, new_rel);
227        if let Err(e) = self.registry.save() {
228            log::error!("Failed to persist asset registry after rename: {e}");
229        }
230        self.rebuild_index()
231    }
232
233    /// Moves an asset into `dest_dir` (forward-slash folder, `""` = assets root),
234    /// keeping its file name. Thin wrapper over [`Self::rename_asset`].
235    pub fn move_asset(&mut self, src_rel: &str, dest_dir: &str) -> Result<()> {
236        let file_name = src_rel.rsplit('/').next().unwrap_or(src_rel);
237        let new_rel = if dest_dir.is_empty() {
238            file_name.to_string()
239        } else {
240            format!("{}/{}", dest_dir.trim_end_matches('/'), file_name)
241        };
242        self.rename_asset(src_rel, &new_rel)
243    }
244
245    /// Sends an asset to the OS recycle bin (reversible), drops its registry
246    /// entry, and rebuilds the index.
247    pub fn delete_to_trash(&mut self, rel: &str) -> Result<()> {
248        let abs = self.abs_of(rel);
249        if !abs.exists() {
250            bail!("Asset does not exist: {}", abs.display());
251        }
252        trash::delete(&abs)
253            .with_context(|| format!("Failed to move {} to the recycle bin", abs.display()))?;
254        self.registry.remove(rel);
255        if let Err(e) = self.registry.save() {
256            log::error!("Failed to persist asset registry after delete: {e}");
257        }
258        self.rebuild_index()
259    }
260
261    /// Duplicates an asset next to itself with a unique ` copy` suffix. The copy
262    /// is a **new asset** (no registry entry → fresh `new_v5` identity). Returns
263    /// the new forward-slash relative path.
264    pub fn duplicate_asset(&mut self, rel: &str) -> Result<String> {
265        let src_abs = self.abs_of(rel);
266        if !src_abs.exists() {
267            bail!("Asset does not exist: {}", src_abs.display());
268        }
269        let (dir, file) = match rel.rsplit_once('/') {
270            Some((d, f)) => (d.to_string(), f.to_string()),
271            None => (String::new(), rel.to_string()),
272        };
273        let (stem, ext) = match file.rsplit_once('.') {
274            Some((s, e)) => (s.to_string(), format!(".{e}")),
275            None => (file.clone(), String::new()),
276        };
277        // Find the first free `stem copy`, `stem copy 2`, … name.
278        let mut new_rel = String::new();
279        for n in 1..10_000 {
280            let suffix = if n == 1 {
281                " copy".to_string()
282            } else {
283                format!(" copy {n}")
284            };
285            let candidate_file = format!("{stem}{suffix}{ext}");
286            let candidate = if dir.is_empty() {
287                candidate_file
288            } else {
289                format!("{dir}/{candidate_file}")
290            };
291            if !self.abs_of(&candidate).exists() {
292                new_rel = candidate;
293                break;
294            }
295        }
296        if new_rel.is_empty() {
297            bail!("Could not find a free duplicate name for {rel}");
298        }
299        std::fs::copy(&src_abs, self.abs_of(&new_rel))
300            .with_context(|| format!("Failed to duplicate {}", src_abs.display()))?;
301        self.rebuild_index()?;
302        Ok(new_rel)
303    }
304
305    /// Lists every directory under `assets/` as forward-slash relative paths
306    /// (sorted, excluding the root). The VFS only indexes files, so empty
307    /// folders — including freshly created ones — are only visible through this.
308    pub fn list_dirs(&self) -> Vec<String> {
309        let mut dirs = Vec::new();
310        for entry in walkdir::WalkDir::new(&self.assets_root)
311            .follow_links(false)
312            .into_iter()
313            .filter_map(|e| e.ok())
314        {
315            if !entry.file_type().is_dir() {
316                continue;
317            }
318            let rel = match entry.path().strip_prefix(&self.assets_root) {
319                Ok(r) if !r.as_os_str().is_empty() => r,
320                _ => continue,
321            };
322            // Skip the engine's own `.khora` project dir if it ever sits inside.
323            let rel_fwd = rel
324                .components()
325                .map(|c| c.as_os_str().to_string_lossy().into_owned())
326                .collect::<Vec<_>>()
327                .join("/");
328            if rel_fwd.starts_with('.') {
329                continue;
330            }
331            dirs.push(rel_fwd);
332        }
333        dirs.sort();
334        dirs
335    }
336
337    /// Drains pending hot-reload events. Convenience wrapper so callers
338    /// don't need to reach through `pvfs.watcher`.
339    pub fn poll_changes(&self) -> Vec<AssetChangeEvent> {
340        self.watcher.poll()
341    }
342}