logicaffeine_web/ui/pages/
studio_files.rs1use std::future::Future;
12use std::pin::Pin;
13use std::sync::OnceLock;
14
15use crate::ui::examples::{seeded_examples, ExampleSpec};
16use crate::ui::state::FileNode;
17use logicaffeine_system::fs::{DirEntry, Vfs, VfsError, VfsResult};
18
19pub const EXAMPLES: &str = "/examples";
21
22pub fn example_at(path: &str) -> Option<&'static ExampleSpec> {
24 seeded_examples().find(|spec| spec.vfs_path == path)
25}
26
27pub fn example_text(path: &str) -> Option<&'static str> {
29 example_at(path).map(|spec| spec.source)
30}
31
32pub fn examples_tree() -> FileNode {
35 static TREE: OnceLock<FileNode> = OnceLock::new();
36 TREE.get_or_init(|| FileNode::from_paths(seeded_examples().map(|spec| spec.vfs_path))).clone()
37}
38
39fn normal(path: &str) -> String {
41 format!("/{}", path.trim_matches('/'))
42}
43
44enum Layer {
46 Example(&'static str),
48 Folder,
50 Missing,
52 Store,
54}
55
56fn layer(path: &str) -> Layer {
57 let path = normal(path);
58 if path != EXAMPLES && !path.starts_with("/examples/") {
59 return Layer::Store;
60 }
61 match example_at(&path) {
62 Some(spec) => Layer::Example(spec.source),
63 None if example_entries(&path).is_some() => Layer::Folder,
64 None => Layer::Missing,
65 }
66}
67
68fn example_entries(dir: &str) -> Option<Vec<DirEntry>> {
70 let inside = if dir == "/" { "/".to_string() } else { format!("{dir}/") };
71 let mut entries: Vec<DirEntry> = Vec::new();
72 for spec in seeded_examples() {
73 let Some(rest) = spec.vfs_path.strip_prefix(inside.as_str()) else { continue };
74 let (name, is_directory) = match rest.split_once('/') {
75 Some((folder, _)) => (folder, true),
76 None => (rest, false),
77 };
78 if !entries.iter().any(|entry| entry.name == name) {
79 entries.push(DirEntry { name: name.to_string(), is_directory });
80 }
81 }
82 (!entries.is_empty()).then_some(entries)
83}
84
85fn read_only(path: &str) -> VfsError {
86 VfsError::PermissionDenied(format!("{path}: the shipped examples are read-only. Save a copy under /workspace to change it."))
87}
88
89fn not_found(path: &str) -> VfsError {
90 VfsError::NotFound(path.to_string())
91}
92
93#[derive(Clone)]
95pub struct StudioFiles<S> {
96 store: S,
97}
98
99impl<S> StudioFiles<S> {
100 pub fn new(store: S) -> Self {
101 Self { store }
102 }
103
104 pub fn store(&self) -> &S {
106 &self.store
107 }
108}
109
110impl<S: Vfs> StudioFiles<S> {
111 pub async fn retire_old_copies(&self) -> VfsResult<usize> {
117 let root = match self.store.list_dir("/").await {
118 Ok(entries) => entries,
119 Err(VfsError::NotFound(_)) => return Ok(0),
120 Err(e) => return Err(e),
121 };
122 if !root.iter().any(|entry| entry.is_directory && entry.name == EXAMPLES.trim_start_matches('/')) {
123 return Ok(0);
124 }
125 remove_tree(&self.store, EXAMPLES).await
126 }
127}
128
129async fn remove_tree<S: Vfs>(store: &S, dir: &str) -> VfsResult<usize> {
131 let mut removed = 0;
132 for entry in store.list_dir(dir).await? {
133 let path = format!("{dir}/{}", entry.name);
134 if entry.is_directory {
135 removed += Box::pin(remove_tree(store, &path)).await?;
136 } else {
137 store.remove(&path).await?;
138 removed += 1;
139 }
140 }
141 store.remove(dir).await?;
142 Ok(removed + 1)
143}
144
145pub async fn open_store<S: Vfs>(files: &StudioFiles<S>, path: &str) -> (VfsResult<FileNode>, Option<String>) {
149 let mut tree = FileNode::root();
150 let loaded = load_dir_recursive(files, "/", &mut tree).await;
151 let read = files.read_to_string(path).await.ok();
152 (loaded.map(|()| tree), read)
153}
154
155pub async fn load_dir_recursive<V: Vfs>(vfs: &V, path: &str, parent: &mut FileNode) -> VfsResult<()> {
157 let entries = vfs.list_dir(path).await?;
158
159 for entry in entries {
160 let full_path = if path == "/" {
161 format!("/{}", entry.name)
162 } else {
163 format!("{}/{}", path, entry.name)
164 };
165
166 if entry.is_directory {
167 let mut dir_node = FileNode::directory(entry.name.clone(), full_path.clone());
168 let _ = Box::pin(load_dir_recursive(vfs, &full_path, &mut dir_node)).await;
170 parent.children.push(dir_node);
171 } else {
172 parent.children.push(FileNode::file(entry.name, full_path));
173 }
174 }
175
176 parent.sort_children();
177
178 Ok(())
179}
180
181#[cfg(not(target_arch = "wasm32"))]
183type Answer<'a, T> = Pin<Box<dyn Future<Output = VfsResult<T>> + Send + 'a>>;
184#[cfg(target_arch = "wasm32")]
185type Answer<'a, T> = Pin<Box<dyn Future<Output = VfsResult<T>> + 'a>>;
186
187impl<S: Vfs> Vfs for StudioFiles<S> {
188 fn read<'a, 'b, 'f>(&'a self, path: &'b str) -> Answer<'f, Vec<u8>>
189 where
190 'a: 'f,
191 'b: 'f,
192 Self: 'f,
193 {
194 Box::pin(async move {
195 match layer(path) {
196 Layer::Example(text) => Ok(text.as_bytes().to_vec()),
197 Layer::Folder | Layer::Missing => Err(not_found(path)),
198 Layer::Store => self.store.read(path).await,
199 }
200 })
201 }
202
203 fn read_to_string<'a, 'b, 'f>(&'a self, path: &'b str) -> Answer<'f, String>
204 where
205 'a: 'f,
206 'b: 'f,
207 Self: 'f,
208 {
209 Box::pin(async move {
210 match layer(path) {
211 Layer::Example(text) => Ok(text.to_string()),
212 Layer::Folder | Layer::Missing => Err(not_found(path)),
213 Layer::Store => self.store.read_to_string(path).await,
214 }
215 })
216 }
217
218 fn write<'a, 'b, 'c, 'f>(&'a self, path: &'b str, contents: &'c [u8]) -> Answer<'f, ()>
219 where
220 'a: 'f,
221 'b: 'f,
222 'c: 'f,
223 Self: 'f,
224 {
225 Box::pin(async move {
226 match layer(path) {
227 Layer::Store => self.store.write(path, contents).await,
228 _ => Err(read_only(path)),
229 }
230 })
231 }
232
233 fn append<'a, 'b, 'c, 'f>(&'a self, path: &'b str, contents: &'c [u8]) -> Answer<'f, ()>
234 where
235 'a: 'f,
236 'b: 'f,
237 'c: 'f,
238 Self: 'f,
239 {
240 Box::pin(async move {
241 match layer(path) {
242 Layer::Store => self.store.append(path, contents).await,
243 _ => Err(read_only(path)),
244 }
245 })
246 }
247
248 fn exists<'a, 'b, 'f>(&'a self, path: &'b str) -> Answer<'f, bool>
249 where
250 'a: 'f,
251 'b: 'f,
252 Self: 'f,
253 {
254 Box::pin(async move {
255 match layer(path) {
256 Layer::Example(_) | Layer::Folder => Ok(true),
257 Layer::Missing => Ok(false),
258 Layer::Store => self.store.exists(path).await,
259 }
260 })
261 }
262
263 fn remove<'a, 'b, 'f>(&'a self, path: &'b str) -> Answer<'f, ()>
264 where
265 'a: 'f,
266 'b: 'f,
267 Self: 'f,
268 {
269 Box::pin(async move {
270 match layer(path) {
271 Layer::Store => self.store.remove(path).await,
272 _ => Err(read_only(path)),
273 }
274 })
275 }
276
277 fn create_dir_all<'a, 'b, 'f>(&'a self, path: &'b str) -> Answer<'f, ()>
278 where
279 'a: 'f,
280 'b: 'f,
281 Self: 'f,
282 {
283 Box::pin(async move {
284 match layer(path) {
285 Layer::Folder => Ok(()),
286 Layer::Store => self.store.create_dir_all(path).await,
287 Layer::Example(_) | Layer::Missing => Err(read_only(path)),
288 }
289 })
290 }
291
292 fn rename<'a, 'b, 'c, 'f>(&'a self, from: &'b str, to: &'c str) -> Answer<'f, ()>
293 where
294 'a: 'f,
295 'b: 'f,
296 'c: 'f,
297 Self: 'f,
298 {
299 Box::pin(async move {
300 match (layer(from), layer(to)) {
301 (Layer::Store, Layer::Store) => self.store.rename(from, to).await,
302 (Layer::Store, _) => Err(read_only(to)),
303 _ => Err(read_only(from)),
304 }
305 })
306 }
307
308 fn list_dir<'a, 'b, 'f>(&'a self, path: &'b str) -> Answer<'f, Vec<DirEntry>>
309 where
310 'a: 'f,
311 'b: 'f,
312 Self: 'f,
313 {
314 Box::pin(async move {
315 if normal(path) == "/" {
316 let mut entries = match self.store.list_dir(path).await {
317 Ok(entries) => entries,
318 Err(VfsError::NotFound(_)) => Vec::new(),
319 Err(e) => return Err(e),
320 };
321 let examples = EXAMPLES.trim_start_matches('/');
322 entries.retain(|entry| entry.name != examples);
323 entries.extend(example_entries("/").unwrap_or_default());
324 return Ok(entries);
325 }
326 match layer(path) {
327 Layer::Folder => Ok(example_entries(&normal(path)).unwrap_or_default()),
328 Layer::Example(_) | Layer::Missing => Err(not_found(path)),
329 Layer::Store => self.store.list_dir(path).await,
330 }
331 })
332 }
333}
334
335#[cfg(test)]
336#[path = "../../../../../crates/logicaffeine_tests/unit/logicaffeine_web/ui/pages/studio_files/tests.rs"]
337mod tests;