Skip to main content

logicaffeine_web/ui/pages/
studio_files.rs

1//! The Studio's files: the shipped examples, read-only and served from the site itself, over the
2//! reader's own store (the browser's OPFS, or IndexedDB in a private window).
3//!
4//! Every shipped example is compiled into the site, so the Studio never copies one into the
5//! reader's store. `/examples` is a read-only layer answered from memory, and every other path is
6//! the reader's. Opening the Studio writes nothing, the examples' part of the file tree exists
7//! before any store answers ([`examples_tree`]), and a program still reads an example by its path.
8//! Copies an earlier Studio wrote under `/examples` never show, and
9//! [`StudioFiles::retire_old_copies`] removes them in the background.
10
11use 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
19/// Where the shipped examples live.
20pub const EXAMPLES: &str = "/examples";
21
22/// The shipped example at `path`.
23pub fn example_at(path: &str) -> Option<&'static ExampleSpec> {
24    seeded_examples().find(|spec| spec.vfs_path == path)
25}
26
27/// The text of the shipped example at `path`.
28pub fn example_text(path: &str) -> Option<&'static str> {
29    example_at(path).map(|spec| spec.source)
30}
31
32/// The examples' part of the file tree, in the file browser's order: what the tree shows before the
33/// reader's store has answered.
34pub 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
39/// `path` as the Studio names a file: one leading slash and no trailing one.
40fn normal(path: &str) -> String {
41    format!("/{}", path.trim_matches('/'))
42}
43
44/// What answers for a path.
45enum Layer {
46    /// A shipped example, with its text.
47    Example(&'static str),
48    /// A folder of shipped examples.
49    Folder,
50    /// A path under `/examples` that no example has.
51    Missing,
52    /// Anything outside `/examples`: the reader's store.
53    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
68/// What the example folder at `dir` holds, or `None` when no example lives under it.
69fn 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/// The Studio's files: the shipped examples over the reader's store.
94#[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    /// The reader's store underneath.
105    pub fn store(&self) -> &S {
106        &self.store
107    }
108}
109
110impl<S: Vfs> StudioFiles<S> {
111    /// Remove the copies of the examples an earlier Studio wrote into the reader's store, every file
112    /// and folder under `/examples`, and count what went. They have not shown since the examples
113    /// came from the site, and nothing else writes there, since the reader's files live in
114    /// `/workspace`. A store with no such folder costs one listing. A removal cut short finishes on
115    /// the next visit.
116    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
129/// Remove the folder at `dir` from `store`, its contents first, and count every entry removed.
130async 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
145/// What the Studio asks of its files when it opens: the whole file tree (the examples from the
146/// site, the reader's own files from the store) and the text of `path`, the file it opened with,
147/// or `None` when there is no such file. It writes nothing.
148pub 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
155/// Load the folder at `path` from `vfs` into `parent`, every level in the file browser's order.
156pub 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            // Recursively load subdirectories
169            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/// A `Vfs` call's answer: a boxed future, `Send` where the trait asks for one.
182#[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;