Skip to main content

blockworx_web/
library.rs

1//! The documents the origin holds: what a tab opens on, where a new one is
2//! born, which containers the File menu offers, and the two doors a
3//! container travels in and out through.
4//!
5//! The desktop's library is a directory; this one is [`Root`], and
6//! everything else about it is the same — the born three-word name, the
7//! recent list, the rename that renames the container, the refusal of a name
8//! something already stands under. The policies are [`blockworx_store`]'s and
9//! are consumed here rather than written a second time.
10//!
11//! Every door is a promise, because origin-private storage is. What that
12//! costs the shell is in [`crate::shell`]: a door is awaited off the frame
13//! and the document it lands is adopted under a fresh borrow.
14
15use blockworx_opfs::{Missing, Root};
16use blockworx_store::container::{Container, Discarded, Glance, discard_unclaimed};
17use blockworx_store::doc::Doc;
18use blockworx_store::handle::{Clock, Store};
19use blockworx_store::history::humanize;
20use blockworx_store::naming::{candidates, entropy};
21use blockworx_store::record::WallTime;
22use blockworx_store::storage::{Any, DocumentRef, Name};
23use blockworx_store::{recent, transfer};
24use core::time::Duration;
25use std::cell::RefCell;
26
27/// One diagram the origin holds, as the Diagrams section lists it.
28#[derive(Clone, PartialEq, Debug)]
29pub struct Listed {
30    /// The container's own name, which a door is raised with.
31    pub name: String,
32    /// Its newest rev and when that was written: `None` where no rev has
33    /// been, or where the container could not be read.
34    pub glance: Option<Glance>,
35}
36
37impl Listed {
38    /// What the document is called — the name the breadcrumb shows for it.
39    #[must_use]
40    pub fn document(&self) -> String {
41        blockworx_editor::import::file_stem(&self.name)
42    }
43
44    /// How long ago the newest rev was written, and which it is, as a row
45    /// says it against `now`.
46    #[must_use]
47    pub fn edited(&self, now: WallTime) -> Option<String> {
48        let glance = self.glance?;
49        let rev = glance.rev.get();
50        Some(
51            match now.unix_millis().checked_sub(glance.written.unix_millis()) {
52                Some(elapsed) => format!(
53                    "Edited {} \u{00b7} rev {rev}",
54                    humanize(Duration::from_millis(elapsed))
55                ),
56                // A stamp in the future is another machine's clock: the rev
57                // is still worth saying.
58                None => format!("rev {rev}"),
59            },
60        )
61    }
62}
63
64/// What the tab opened on, and what it could not.
65pub struct Opened {
66    pub library: Library,
67    pub doc: Doc,
68    /// The standing fact a session with no files behind it owes the user —
69    /// a page that is not a secure context, or a browser that would not
70    /// open the origin's storage.
71    pub notice: Option<String>,
72}
73
74/// The origin's documents, and the short list of them worth offering.
75pub struct Library {
76    /// `None` where the origin refused its storage, which is every page that
77    /// is not a secure context. The editor opens either way, on a scratch
78    /// session that persists nothing.
79    root: Option<Root>,
80    recent: RefCell<Vec<DocumentRef>>,
81    kept: crate::prefs::Store,
82}
83
84impl Opened {
85    /// A tab with no origin under it and nothing open — what the chrome's
86    /// own snapshots are rendered over, where there is no browser at all.
87    #[must_use]
88    pub fn detached() -> Self {
89        Opened {
90            library: Library {
91                root: None,
92                recent: RefCell::new(Vec::new()),
93                kept: crate::prefs::Store::default(),
94            },
95            doc: Doc::default(),
96            notice: None,
97        }
98    }
99}
100
101impl Library {
102    /// Open the origin and the document the tab last had, before the first
103    /// frame runs.
104    ///
105    /// Nothing here refuses to start. An origin that will not open, a
106    /// remembered document that is gone, a container that cannot be born —
107    /// each falls back to the next thing down and says what happened.
108    pub async fn opening(kept: crate::prefs::Store) -> Opened {
109        let remembered = kept.recent();
110        let root = match Root::open().await {
111            Ok(root) => root,
112            Err(why) => {
113                return Opened {
114                    library: Library {
115                        root: None,
116                        recent: RefCell::new(remembered),
117                        kept,
118                    },
119                    doc: Doc::default(),
120                    notice: Some(format!(
121                        "Nothing will be saved: this page has no private storage ({why}). \
122                         A page served over https or from localhost has one."
123                    )),
124                };
125            }
126        };
127        let library = Library {
128            root: Some(root),
129            recent: RefCell::new(remembered),
130            kept,
131        };
132        let (doc, notice) = library.stands_on().await;
133        // After the document this tab stands on, because the lock it took on
134        // the way in is what keeps the sweep off it.
135        library.sweeps_unclaimed().await;
136        Opened {
137            library,
138            doc,
139            notice,
140        }
141    }
142
143    /// The document a tab opens on: always a newborn. A document that breaks
144    /// the app must not be the one every reload walks back into; the ones
145    /// before it are a menu away.
146    pub(crate) async fn stands_on(&self) -> (Doc, Option<String>) {
147        match self.born().await {
148            Ok(store) => (Doc::attached(store), None),
149            Err(why) => (Doc::default(), Some(why)),
150        }
151    }
152
153    /// The containers a tab that is gone left behind: one born under a name
154    /// nobody kept, never written into, and held by no view, goes before the
155    /// library lists it.
156    ///
157    /// The desktop sweeps these on the way out. A closed tab runs nothing, so
158    /// the question is asked the next time one opens instead, and the lock
159    /// stands in for what a session would otherwise remember making.
160    async fn sweeps_unclaimed(&self) {
161        let Some(root) = &self.root else {
162            return;
163        };
164        let held = match root.containers().await {
165            Ok(held) => held,
166            Err(why) => {
167                tracing::error!("the origin would not list its documents: {why}");
168                return;
169            }
170        };
171        for name in held {
172            let storage = match root.container(&name, Missing::IsNothing).await {
173                Ok(storage) => storage,
174                Err(why) => {
175                    tracing::warn!("{name} could not be looked at: {why}");
176                    continue;
177                }
178            };
179            match discard_unclaimed(&storage, blockworx_store::history::now()).await {
180                Ok(Discarded::Removed) => tracing::info!("removed {name}, which held no edit"),
181                Ok(Discarded::Kept) => {}
182                Err(why) => tracing::warn!("{name} could not be tidied away: {why}"),
183            }
184        }
185    }
186
187    /// Every container the origin holds, in name order — what File ▸ Open
188    /// lists, with the recent ones first.
189    pub async fn containers(&self) -> Vec<Name> {
190        let Some(root) = &self.root else {
191            return Vec::new();
192        };
193        let mut held = root.containers().await.unwrap_or_else(|why| {
194            tracing::error!("the origin would not list its documents: {why}");
195            Vec::new()
196        });
197        let recent = self.recent.borrow();
198        held.sort_by_key(|name| {
199            recent
200                .iter()
201                .position(|named| named.as_str() == name.as_str())
202                .unwrap_or(recent.len())
203        });
204        held
205    }
206
207    /// Every container the origin holds with a glance at each, newest edit
208    /// first — what the Diagrams section lists. Read without taking any
209    /// container's lock, so one open in this tab or another lists the same.
210    pub async fn listing(&self) -> Vec<Listed> {
211        let Some(root) = &self.root else {
212            return Vec::new();
213        };
214        let mut listed = Vec::new();
215        for name in self.containers().await {
216            let glance = match root.container(&name, Missing::IsNothing).await {
217                Ok(storage) => Container::glance(storage).await.unwrap_or_else(|why| {
218                    tracing::warn!("{name} could not be read: {why}");
219                    None
220                }),
221                Err(why) => {
222                    tracing::warn!("{name} could not be looked at: {why}");
223                    None
224                }
225            };
226            listed.push(Listed {
227                name: name.to_string(),
228                glance,
229            });
230        }
231        listed.sort_by(|a, b| {
232            let written = |entry: &Listed| entry.glance.map(|at| at.written);
233            written(b).cmp(&written(a))
234        });
235        listed
236    }
237
238    /// A document born under a name nothing in the origin stands under.
239    ///
240    /// # Errors
241    /// The sentence the canvas is told: no storage to be born in, no unused
242    /// name, or the container that would not be laid down.
243    pub async fn born(&self) -> Result<Store<Any>, String> {
244        let root = self.origin()?;
245        for called in candidates(entropy) {
246            match root.holds(&called).await {
247                Ok(true) => continue,
248                Ok(false) => {}
249                Err(why) => return Err(failed_to("create a new diagram", &why)),
250            }
251            let storage = root
252                .container(&called, Missing::IsMade)
253                .await
254                .map_err(|why| failed_to("create a new diagram", &why))?;
255            return Store::creating(Any::new(storage), Clock::System)
256                .await
257                .map_err(|why| failed_to("create a new diagram", &why));
258        }
259        Err("Failed to create a new diagram: no unused name to give it".to_owned())
260    }
261
262    /// The container `named`, open.
263    ///
264    /// A held lock is an outcome rather than a failure: the store opens
265    /// read-only with [`ReadOnlyReason::Locked`], which is how a second tab
266    /// of one document lands. Only a name nothing stands under — or stands
267    /// under as something that is not a container — is refused.
268    ///
269    /// [`ReadOnlyReason::Locked`]: blockworx_store::container::ReadOnlyReason::Locked
270    ///
271    /// # Errors
272    /// The sentence the canvas is told.
273    pub async fn opens(&self, named: &DocumentRef) -> Result<Store<Any>, String> {
274        let root = self.origin()?;
275        let called =
276            Name::new(named.as_str()).ok_or_else(|| format!("{named} is not a diagram"))?;
277        let storage = root
278            .container(&called, Missing::IsNothing)
279            .await
280            .map_err(|why| why.to_string())?;
281        Store::opening(Any::new(storage), Clock::System)
282            .await
283            .map_err(|why| why.to_string())
284    }
285
286    /// Call `doc`'s container something else, which is what renaming the
287    /// document means. The session carries straight on into it.
288    ///
289    /// # Errors
290    /// The sentence the canvas is told.
291    pub async fn renames(&self, doc: &mut Doc, to: &str) -> Result<(), String> {
292        let was = doc
293            .container_name()
294            .ok_or("This session has no diagram to rename")?;
295        let called =
296            Name::of_document(to).ok_or_else(|| format!("\u{201c}{to}\u{201d} is not a name"))?;
297        if called == was {
298            return Ok(());
299        }
300        doc.renaming_to(&called)
301            .await
302            .map_err(|why| failed_to(&format!("rename {was}"), &why))?;
303        self.forgets(&DocumentRef::new(was.as_str()));
304        self.remembers(&called);
305        Ok(())
306    }
307
308    /// Remove `named` and everything in it.
309    ///
310    /// # Errors
311    /// The sentence the canvas is told.
312    pub async fn removes(&self, named: &Name) -> Result<(), String> {
313        let root = self.origin()?;
314        root.remove(named)
315            .await
316            .map_err(|why| failed_to(&format!("delete {named}"), &why))?;
317        self.forgets(&DocumentRef::new(named.as_str()));
318        Ok(())
319    }
320
321    /// Remove `named`, which this tab does not have open — refused where
322    /// another tab does, whose session would be left writing into nothing.
323    ///
324    /// # Errors
325    /// The sentence the canvas is told.
326    pub async fn removes_closed(&self, named: &Name) -> Result<(), String> {
327        let root = self.origin()?;
328        root.remove_unheld(named)
329            .await
330            .map_err(|why| failed_to(&format!("delete {named}"), &why))?;
331        self.forgets(&DocumentRef::new(named.as_str()));
332        Ok(())
333    }
334
335    /// The container `named`, as the one file it travels in.
336    ///
337    /// Read through a second handle on the same directory rather than
338    /// through the session's own: the session holds a *resident* container,
339    /// so what it owes the origin has to have been drained before this is
340    /// asked — which is the shell's to see to.
341    ///
342    /// # Errors
343    /// The sentence the canvas is told.
344    pub async fn packs(&self, named: &Name) -> Result<Vec<u8>, String> {
345        let root = self.origin()?;
346        let storage = root
347            .container(named, Missing::IsNothing)
348            .await
349            .map_err(|why| failed_to(&format!("export {named}"), &why))?;
350        transfer::pack(&storage)
351            .await
352            .map_err(|why| failed_to(&format!("export {named}"), &why))
353    }
354
355    /// Lay the container `archive` holds down in the origin under the
356    /// document name `called` suggests, and open it.
357    ///
358    /// The name is refused rather than merged into if something already
359    /// stands under it: an import brings a diagram in, it does not overwrite
360    /// one.
361    ///
362    /// # Errors
363    /// The sentence the canvas is told.
364    pub async fn unpacks(&self, called: &str, archive: &[u8]) -> Result<Store<Any>, String> {
365        let root = self.origin()?;
366        let named =
367            carried(called).ok_or_else(|| format!("\u{201c}{called}\u{201d} is not a diagram"))?;
368        let taken = root
369            .holds(&named)
370            .await
371            .map_err(|why| failed_to(&format!("import {named}"), &why))?;
372        if taken {
373            return Err(format!(
374                "Failed to import {named}: this origin already holds a diagram of that name"
375            ));
376        }
377        let storage = root
378            .container(&named, Missing::IsMade)
379            .await
380            .map_err(|why| failed_to(&format!("import {named}"), &why))?;
381        // A container the store cannot open is not one that arrived: the
382        // half-laid directory goes rather than joining the list as a
383        // document that will not open.
384        let laid = transfer::unpack(&storage, archive).await;
385        let opened = match laid {
386            Ok(()) => Store::opening(Any::new(storage), Clock::System)
387                .await
388                .map_err(|why| failed_to(&format!("import {named}"), &why)),
389            Err(why) => Err(failed_to(&format!("import {named}"), &why)),
390        };
391        if opened.is_err() {
392            let _ = root.remove(&named).await;
393        }
394        opened
395    }
396
397    /// Put `named` at the front of the list the File menu offers.
398    pub fn remembers(&self, named: &Name) {
399        let mut recent = self.recent.borrow_mut();
400        recent::remember(&mut recent, &DocumentRef::new(named.as_str()));
401        self.kept.remembers(&recent);
402    }
403
404    fn forgets(&self, named: &DocumentRef) {
405        let mut recent = self.recent.borrow_mut();
406        recent::forget(&mut recent, named);
407        self.kept.remembers(&recent);
408    }
409
410    fn origin(&self) -> Result<&Root, String> {
411        self.root
412            .as_ref()
413            .ok_or_else(|| "This page has no private storage to keep a diagram in".to_owned())
414    }
415}
416
417/// Ask for a `.bwx.zip` and hand what comes back to the import door.
418///
419/// The input is made, clicked and let go rather than standing hidden in the
420/// component tree: a picker is a moment, and an element that outlived it
421/// would be a second place its state could be.
422pub fn picks_an_archive(shell: &crate::shell::Shell) {
423    use wasm_bindgen::{JsCast as _, prelude::Closure};
424
425    let Some(input) = crate::shell::window()
426        .and_then(|window| window.document())
427        .and_then(|document| document.create_element("input").ok())
428        .and_then(|element| element.dyn_into::<web_sys::HtmlInputElement>().ok())
429    else {
430        return;
431    };
432    input.set_type("file");
433    input.set_accept(".zip");
434    let shell = shell.clone();
435    let picked = input.clone();
436    let taken = Closure::once_into_js(move || {
437        let Some(file) = picked.files().and_then(|files| files.get(0)) else {
438            return;
439        };
440        let called = file.name();
441        crate::shell::spawn(async move {
442            match wasm_bindgen_futures::JsFuture::from(file.array_buffer()).await {
443                Ok(read) => {
444                    shell.imports_archive(called, js_sys::Uint8Array::new(&read).to_vec());
445                }
446                Err(why) => shell.failed(format!("Could not read {called}: {why:?}")),
447            }
448        });
449    });
450    input.set_onchange(Some(taken.unchecked_ref()));
451    input.click();
452}
453
454fn carried(file: &str) -> Option<Name> {
455    Name::of_archive(file)
456}
457
458fn failed_to(what: &str, why: &impl std::fmt::Display) -> String {
459    format!("Failed to {what}: {why}")
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465
466    fn named(file: &str) -> String {
467        carried(file)
468            .map(|name| name.to_string())
469            .unwrap_or_default()
470    }
471
472    #[test]
473    fn a_picked_archive_names_the_document_it_carries() {
474        assert_eq!(named("engine.bwx.zip"), "engine.bwx");
475        assert_eq!(named("engine.bwx"), "engine.bwx");
476        assert_eq!(named("engine.zip"), "engine.bwx");
477        assert_eq!(
478            named("notes.v2.bwx.zip"),
479            "notes.v2.bwx",
480            "a dotted document name keeps all of itself",
481        );
482        assert_eq!(carried("../escaped.zip"), None, "and a path is no name");
483    }
484
485    /// A row says how long ago its newest rev was written and which rev it
486    /// is; a stamp from a clock ahead of this one still names the rev, and a
487    /// container with no rev says nothing.
488    #[test]
489    fn a_listed_row_says_when_it_was_edited_and_at_which_rev() {
490        let now = WallTime::from_unix_millis(10 * 3_600_000);
491        let listed = |written| Listed {
492            name: "rig.bwx".to_owned(),
493            glance: Some(Glance {
494                rev: blockworx_doc::fixtures::rev(12),
495                written,
496            }),
497        };
498        assert_eq!(
499            listed(WallTime::from_unix_millis(7 * 3_600_000))
500                .edited(now)
501                .as_deref(),
502            Some("Edited 3 hours ago \u{00b7} rev 12"),
503        );
504        assert_eq!(
505            listed(WallTime::from_unix_millis(11 * 3_600_000))
506                .edited(now)
507                .as_deref(),
508            Some("rev 12"),
509        );
510        let fresh = Listed {
511            name: "rig.bwx".to_owned(),
512            glance: None,
513        };
514        assert_eq!(fresh.edited(now), None);
515        assert_eq!(fresh.document(), "rig");
516    }
517
518    /// A page with no origin storage still opens, and every door on it says
519    /// the same thing rather than failing silently.
520    #[test]
521    fn a_library_with_no_origin_refuses_every_door_in_words() {
522        let library = Library {
523            root: None,
524            recent: RefCell::new(Vec::new()),
525            kept: crate::prefs::Store::default(),
526        };
527        let Err(refusal) = library.origin() else {
528            panic!("a library with no root has no origin to answer");
529        };
530        assert!(refusal.contains("no private storage"), "{refusal}");
531    }
532}