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(®istry)
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}