Skip to main content

blockworx/
file.rs

1//! The File flow's platform half: the picker a [`FileRequest`] opens, and
2//! which containers we offer to reopen. What a *path* means lives in
3//! [`blockworx_store::file`] and what a request *is* in
4//! [`blockworx_tools::file`]; both are re-exported here so the flow reads as
5//! one vocabulary.
6//!
7//! Native only — the browser has no filesystem to open a container from.
8
9use std::path::PathBuf;
10
11use serde::{Deserialize, Serialize};
12
13pub use blockworx_tools::file::{FilePick, FileRequest, PickReceiver, SaveScope};
14
15pub use blockworx_store::file::{
16    as_container_path, container_name, create_container, names_a_container, names_an_archive,
17    open_archive, open_container, refused_as_a_diagram, renamed_beside, save_container_through,
18};
19pub use blockworx_store::storage::CONTAINER_EXTENSION;
20
21/// What a container's *document* is called: the directory's own name without
22/// the extension — `engine.bwx` holds the document `engine`. The name an
23/// export stamps its provenance with. Here rather than one crate down, so
24/// `.bwx` and an opened `.json` are stemmed by one function.
25pub fn document_name(root: &std::path::Path) -> String {
26    crate::import::file_stem(&container_name(root))
27}
28
29/// Where the recent-files list is kept in the eframe storage DB.
30const RECENT_KEY: &str = "recent_containers";
31
32/// The containers to offer reopening, most recent first. Persisted through
33/// the eframe storage DB beside the appearance preferences.
34#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
35#[serde(transparent)]
36pub struct RecentFiles(Vec<PathBuf>);
37
38impl RecentFiles {
39    pub fn paths(&self) -> &[PathBuf] {
40        &self.0
41    }
42
43    /// Put `path` at the front, where reopening it moves it back to.
44    pub fn remember(&mut self, path: &std::path::Path) {
45        blockworx_store::recent::remember(&mut self.0, &path.to_path_buf());
46    }
47
48    /// Drop `path` — what opening a container that is gone or unreadable
49    /// does, so a dead entry is offered once and not twice.
50    pub fn forget(&mut self, path: &std::path::Path) {
51        blockworx_store::recent::forget(&mut self.0, &path.to_path_buf());
52    }
53
54    pub fn restore(storage: &dyn eframe::Storage) -> Self {
55        storage
56            .get_string(RECENT_KEY)
57            .and_then(|text| serde_json::from_str(&text).ok())
58            .unwrap_or_default()
59    }
60
61    pub fn save(&self, storage: &mut dyn eframe::Storage) {
62        match serde_json::to_string(self) {
63            Ok(text) => storage.set_string(RECENT_KEY, text),
64            Err(e) => tracing::error!("Failed to serialize the recent-files list: {e}"),
65        }
66    }
67}
68
69/// Open `request`'s dialog off the UI thread (so the canvas keeps
70/// repainting) and return the channel its pick arrives on.
71pub fn spawn_file_dialog(ctx: &egui::Context, request: FileRequest) -> PickReceiver {
72    let (tx, rx) = std::sync::mpsc::channel();
73    let thread_ctx = ctx.clone();
74    std::thread::spawn(move || {
75        let _ = tx.send(pick(request));
76        thread_ctx.request_repaint();
77    });
78    ctx.request_repaint();
79    rx
80}
81
82/// The blocking half of [`spawn_file_dialog`]. A diagram is a directory, so
83/// opening one is a folder pick; making one is a save pick, whose suggested
84/// name carries the extension.
85///
86/// No native folder picker filters by suffix — rfd offers filters to file
87/// pickers only — so the picker opens on folders and the *choice* is checked
88/// afterwards, by [`refused_as_a_diagram`], with the refusal going out through
89/// the ordinary failure path.
90fn pick(request: FileRequest) -> Option<FilePick> {
91    match request {
92        FileRequest::OpenContainer => rfd::FileDialog::new()
93            .set_title("Open a diagram (.bwx)")
94            .pick_folder()
95            .map(FilePick::Container),
96        FileRequest::SaveAsContainer(scope) => rfd::FileDialog::new()
97            .set_title("Save diagram as")
98            .set_file_name(format!("diagram.{CONTAINER_EXTENSION}"))
99            .save_file()
100            .map(|path| FilePick::NewContainer(as_container_path(&path), scope)),
101    }
102}
103
104#[cfg(test)]
105pub(crate) mod tests {
106    use super::*;
107    use std::collections::HashMap;
108    use std::path::Path;
109
110    /// The eframe storage seam, in memory: the recent list has to survive a
111    /// round trip through it, and there is no window here to get a real one.
112    #[derive(Default)]
113    pub struct MemoryStorage(HashMap<String, String>);
114
115    impl eframe::Storage for MemoryStorage {
116        fn get_string(&self, key: &str) -> Option<String> {
117            self.0.get(key).cloned()
118        }
119
120        fn set_string(&mut self, key: &str, value: String) {
121            self.0.insert(key.to_owned(), value);
122        }
123
124        fn remove_string(&mut self, key: &str) {
125            self.0.remove(key);
126        }
127
128        fn flush(&mut self) {}
129    }
130
131    #[test]
132    fn a_container_names_the_document_inside_it_without_its_extension() {
133        assert_eq!(
134            document_name(Path::new("/home/ada/motor-controller.bwx")),
135            "motor-controller"
136        );
137        assert_eq!(
138            document_name(Path::new("/home/ada/notes.v2.bwx")),
139            "notes.v2"
140        );
141    }
142
143    #[test]
144    fn the_recent_list_round_trips_through_the_storage_seam() {
145        let mut storage = MemoryStorage::default();
146        assert!(
147            RecentFiles::restore(&storage).paths().is_empty(),
148            "precondition: nothing has been stored yet",
149        );
150
151        let mut recent = RecentFiles::default();
152        recent.remember(Path::new("/tmp/one.bwx"));
153        recent.remember(Path::new("/tmp/two.bwx"));
154        recent.save(&mut storage);
155
156        assert_eq!(RecentFiles::restore(&storage), recent);
157    }
158
159    /// A stored blob from a build that spelled the list differently must not
160    /// take the app down with it — the same forgiveness the preferences blob
161    /// gets.
162    #[test]
163    fn a_malformed_stored_list_restores_empty() {
164        use eframe::Storage as _;
165
166        let mut storage = MemoryStorage::default();
167        storage.set_string(RECENT_KEY, "not a list".to_owned());
168        assert!(RecentFiles::restore(&storage).paths().is_empty());
169    }
170}