Skip to main content

khora_editor/
hot_reload.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//! Project hot-reload pump.
10//!
11//! Drains pending filesystem-change events from the project watcher,
12//! invalidates the `AssetService` cache for modified UUIDs, and reindexes
13//! when files are added or removed. Run once per frame, before the agents
14//! see the world, so a coherent VFS is in scope for the rest of the tick.
15
16use std::collections::HashSet;
17use std::sync::{Arc, Mutex};
18
19use khora_sdk::editor_ui::AssetEntry;
20use khora_sdk::AssetChangeKind;
21use khora_sdk::EditorState;
22
23use crate::project_vfs::ProjectVfs;
24
25/// Drain the project's filesystem watcher and apply the appropriate
26/// invalidation / reindex actions. The asset browser cache is rebuilt
27/// whenever a full reindex was queued.
28pub fn pump(pvfs_mutex: &Arc<Mutex<ProjectVfs>>, editor_state: &Arc<Mutex<EditorState>>) {
29    let Ok(mut pvfs) = pvfs_mutex.lock() else {
30        return;
31    };
32    let events = pvfs.poll_changes();
33    if events.is_empty() {
34        return;
35    }
36
37    // Coalesce Modified by path (one save fires a flurry); Created/Removed
38    // trigger a full reindex.
39    let mut needs_reindex = false;
40    let mut modified: HashSet<String> = HashSet::new();
41    for e in events {
42        match e.kind {
43            AssetChangeKind::Modified => {
44                modified.insert(e.rel_path);
45            }
46            AssetChangeKind::Created | AssetChangeKind::Removed => {
47                log::info!(
48                    "Hot reload: {:?} '{}' — full reindex queued",
49                    e.kind,
50                    e.rel_path
51                );
52                needs_reindex = true;
53            }
54        }
55    }
56
57    // Resolve the UUID through the identity registry (not the event's raw
58    // path-derived value) so a modified *renamed* asset invalidates the right
59    // cache entry.
60    for rel in &modified {
61        let uuid = pvfs.resolve_uuid(rel);
62        let dropped = pvfs.asset_service.invalidate(&uuid);
63        log::info!(
64            "Hot reload: Modified '{}' (cache dropped: {})",
65            rel,
66            dropped
67        );
68    }
69
70    if !needs_reindex {
71        return;
72    }
73    if let Err(e) = pvfs.rebuild_index() {
74        log::error!("Hot reload: failed to rebuild index: {:#}", e);
75        return;
76    }
77    let entries = collect_asset_entries(&pvfs);
78    let dirs = pvfs.list_dirs();
79    if let Ok(mut state) = editor_state.lock() {
80        state.asset_entries = entries;
81        state.asset_dirs = dirs;
82        state.asset_epoch = state.asset_epoch.wrapping_add(1);
83    }
84}
85
86/// Build an `AssetEntry` cache from the current VFS contents. Used by the
87/// initial project open and when the user browses to a different folder
88/// at runtime.
89pub fn collect_asset_entries(pvfs: &ProjectVfs) -> Vec<AssetEntry> {
90    pvfs.asset_service
91        .vfs()
92        .iter_all()
93        .map(|m| {
94            let rel_str = m.source_path.to_string_lossy().to_string();
95            let name = m
96                .source_path
97                .file_name()
98                .map(|n| n.to_string_lossy().to_string())
99                .unwrap_or_else(|| rel_str.clone());
100            AssetEntry {
101                name,
102                asset_type: m.asset_type_name.clone(),
103                source_path: rel_str,
104            }
105        })
106        .collect()
107}