Skip to main content

blockworx/
library.rs

1//! The documents on disk: what this session opened, where a new one is born,
2//! which containers the File menu offers to reopen, and every door a document
3//! comes in or goes out through. The browser has no filesystem, so on the web
4//! this part is a shape with nothing in it.
5//!
6//! It also owns what the session has to say about its files — the refusals a
7//! door reports and the standing facts about the container — because every one
8//! of them starts here.
9
10#[cfg(not(target_arch = "wasm32"))]
11use std::path::{Path, PathBuf};
12
13use crate::kernel::Session;
14// Every door that settles a panel is a container door, and the browser has
15// none.
16#[cfg(not(target_arch = "wasm32"))]
17use crate::surface::Surface;
18use blockworx_store::doc::Doc;
19
20#[cfg(not(target_arch = "wasm32"))]
21use blockworx_doc::repo::Repo;
22
23/// What the session opens on.
24#[cfg(not(target_arch = "wasm32"))]
25#[derive(Default)]
26pub enum Opening {
27    /// The path a command line named: a `.bwx` container, which attaches, or
28    /// a document file, which opens as a scratch session.
29    Path(PathBuf),
30    /// Nothing was named, so a document is born: a container under a name of
31    /// three words in the documents directory, attached from its first edit.
32    Born,
33    /// Neither — a session with no file behind it. The browser has only this,
34    /// and so does a test driving the editor without a filesystem.
35    #[default]
36    Detached,
37}
38
39#[derive(Default)]
40pub(crate) struct Library {
41    /// The file a *scratch* document was opened from, for the window title.
42    /// `None` on the web (no filesystem) and when nothing was there to open.
43    /// An attached container names itself.
44    #[cfg(not(target_arch = "wasm32"))]
45    opened: Option<String>,
46    /// Where the document this session opened came from, when it was opened
47    /// from an export that carried a provenance block. Advisory and
48    /// session-only: the title block says it, nothing writes it, and saving
49    /// into a container does not carry it past this session.
50    #[cfg(not(target_arch = "wasm32"))]
51    opened_from: Option<blockworx_store::stamp::Provenance>,
52    /// The containers the File menu offers to reopen, persisted through the
53    /// eframe storage DB.
54    #[cfg(not(target_arch = "wasm32"))]
55    pub(crate) recent: crate::file::RecentFiles,
56    /// Where File ▸ New puts the container it creates.
57    #[cfg(not(target_arch = "wasm32"))]
58    documents: blockworx_store::naming::Documents,
59    /// The containers *this session* created that nobody has claimed: born
60    /// under a generated name, never committed to and never renamed. They
61    /// are removed on a clean exit, so launching and quitting leaves the
62    /// documents directory as it was, and they stay off the recent list
63    /// until they are claimed, so it does not fill with dead names.
64    #[cfg(not(target_arch = "wasm32"))]
65    unclaimed: Vec<PathBuf>,
66    /// The File menu's rename box, which has to survive the frames it is
67    /// open across.
68    #[cfg(not(target_arch = "wasm32"))]
69    pub(crate) rename_draft: String,
70    /// The channel an in-flight File-menu dialog delivers its pick on.
71    /// Polled each frame; `None` when no dialog is open.
72    #[cfg(not(target_arch = "wasm32"))]
73    pub(crate) pending_file: Option<crate::file::PickReceiver>,
74}
75
76impl Library {
77    /// Open whatever the session was pointed at, and hand back the document
78    /// it is to stand on. A document born here is the no-argument case.
79    #[cfg(not(target_arch = "wasm32"))]
80    pub(crate) fn opening(
81        opening: Opening,
82        documents: blockworx_store::naming::Documents,
83    ) -> (Self, Doc, Option<String>) {
84        let Startup {
85            doc,
86            opened,
87            from,
88            born,
89            failure,
90        } = match opening {
91            Opening::Path(path) => open_startup_path(&path),
92            Opening::Born => born_attached(&documents),
93            Opening::Detached => Startup::detached(),
94        };
95        let library = Self {
96            opened,
97            opened_from: from,
98            documents,
99            unclaimed: born.into_iter().collect(),
100            ..Self::default()
101        };
102        (library, doc, failure)
103    }
104
105    /// The browser has no filesystem: nothing to open, and nowhere for a
106    /// document to be born.
107    #[cfg(target_arch = "wasm32")]
108    pub(crate) fn opening() -> (Self, Doc, Option<String>) {
109        (Self::default(), Doc::default(), None)
110    }
111
112    /// What a scratch document's source file is called — the one fact about
113    /// the window title and the document's name that belongs to the platform
114    /// rather than to the document.
115    // The browser has no file to have opened one from, so the answer there is
116    // a constant `None` — and the shape of the question is still the shell's.
117    #[cfg_attr(target_arch = "wasm32", expect(clippy::unused_self))]
118    fn opened_name(&self) -> Option<&str> {
119        #[cfg(not(target_arch = "wasm32"))]
120        {
121            self.opened.as_deref()
122        }
123        #[cfg(target_arch = "wasm32")]
124        {
125            None
126        }
127    }
128
129    #[cfg(not(target_arch = "wasm32"))]
130    pub(crate) fn window_title(&self, session: &Session) -> String {
131        session.window_title(self.opened_name())
132    }
133
134    pub(crate) fn document_name(&self, session: &Session) -> String {
135        session.document_name(self.opened_name())
136    }
137
138    /// Where this session's document came from, when it was opened from an
139    /// export. The web build opens no files, so nothing there has a
140    /// provenance to carry.
141    #[cfg_attr(target_arch = "wasm32", expect(clippy::unused_self))]
142    pub(crate) fn opened_from(&self) -> Option<blockworx_store::stamp::Provenance> {
143        #[cfg(not(target_arch = "wasm32"))]
144        {
145            self.opened_from.clone()
146        }
147        #[cfg(target_arch = "wasm32")]
148        {
149            None
150        }
151    }
152
153    /// Restore the recent list from the eframe storage DB.
154    #[cfg(not(target_arch = "wasm32"))]
155    pub(crate) fn restore(&mut self, storage: &dyn eframe::Storage, session: &Session) {
156        self.recent = crate::file::RecentFiles::restore(storage);
157        // A container opened from the command line belongs at the front of
158        // the list too — the restore above would otherwise bury it. One this
159        // session invented does not: it earns its place by being written in
160        // or renamed.
161        if let Some(root) = attached_root(session)
162            && !self.unclaimed.contains(&root)
163        {
164            self.recent.remember(&root);
165        }
166    }
167
168    #[cfg(not(target_arch = "wasm32"))]
169    pub(crate) fn save(&self, storage: &mut dyn eframe::Storage) {
170        self.recent.save(storage);
171    }
172
173    /// Open the File menu's dialog for `request`; the pick comes back through
174    /// [`crate::file::PickReceiver`], polled each frame.
175    #[cfg(not(target_arch = "wasm32"))]
176    pub(crate) fn pick_file(
177        &mut self,
178        ask: &mut crate::dialogs::Ask<'_>,
179        request: crate::file::FileRequest,
180    ) {
181        self.pending_file = Some(ask.file(request));
182    }
183
184    /// Take `doc` as the session's document and settle everything that
185    /// pointed at the last one. The handle it displaces drops here, which is
186    /// what gives a container's lock back — there is nothing to save on the
187    /// way out, since every commit was durable when it was made.
188    #[cfg(not(target_arch = "wasm32"))]
189    fn take_document(&mut self, session: &mut Session, surface: &mut Surface, doc: Doc) {
190        let was = self.document_name(session);
191        session.opens(doc);
192        // The store that just dropped gave a born container its lock back,
193        // so one this session made and left behind can go now rather than
194        // waiting for the exit.
195        self.sweep_unclaimed(session);
196        surface.forget_popup();
197        surface.settle_workspace(&was, &self.document_name(session));
198    }
199
200    /// File ▸ New: a document born attached, under a name of three words. A
201    /// container that cannot be created leaves the session on a scratch
202    /// document — the editor opens either way — and says why.
203    #[cfg(not(target_arch = "wasm32"))]
204    pub(crate) fn new_document(&mut self, session: &mut Session, surface: &mut Surface) {
205        self.opened = None;
206        self.opened_from = None;
207        match self.documents.create(blockworx_store::naming::entropy) {
208            Ok(store) => {
209                if let Some(born) = store.path().map(Path::to_path_buf) {
210                    self.unclaimed.push(born);
211                }
212                self.take_document(session, surface, Doc::attached(store));
213            }
214            Err(failure) => {
215                // After the swap: adopting a document clears the notices
216                // the last one collected, this one among them.
217                self.take_document(session, surface, Doc::default());
218                session.failures.report(failure.notice());
219            }
220        }
221    }
222
223    /// Call this document something else, which renames its container.
224    /// The session carries straight on into it: the log it is appending to
225    /// is held open, so the next edit lands in the renamed container.
226    #[cfg(not(target_arch = "wasm32"))]
227    pub(crate) fn rename_document(
228        &mut self,
229        session: &mut Session,
230        surface: &mut Surface,
231        name: &str,
232    ) {
233        let Some(root) = attached_root(session) else {
234            session
235                .failures
236                .report("This session has no diagram on disk to rename".to_owned());
237            return;
238        };
239        let Some(called) = blockworx_store::storage::Name::of_document(name) else {
240            session
241                .failures
242                .report(format!("\u{201c}{name}\u{201d} is not a diagram name"));
243            return;
244        };
245        if let Err(refusal) = session.doc.rename(&called) {
246            session.failures.report(format!(
247                "Failed to rename {}: {refusal}",
248                crate::file::document_name(&root),
249            ));
250            return;
251        }
252        self.recent.forget(&root);
253        // The panel state follows the document to its new name.
254        surface.settle_workspace(
255            &crate::file::document_name(&root),
256            &self.document_name(session),
257        );
258        // A name of the user's own is a claim on the document, so a
259        // renamed container is never swept and joins the recent list.
260        self.claim(&crate::file::renamed_beside(&root, &called));
261    }
262
263    /// A container this session created stops being unclaimed the moment
264    /// the user commits to it — the first edit, or a name of their own —
265    /// and joins the recent list then, so the list never fills with names
266    /// nobody kept.
267    #[cfg(not(target_arch = "wasm32"))]
268    fn claim(&mut self, root: &Path) {
269        self.unclaimed.retain(|born| born != root);
270        self.recent.remember(root);
271    }
272
273    /// The first record in a born container's log is what claims it. Called
274    /// from the write door, so every path that can write the first one —
275    /// an edit, a paste, an import, a restore — goes through here.
276    #[cfg(not(target_arch = "wasm32"))]
277    pub(crate) fn claim_if_written(&mut self, session: &Session) {
278        if self.unclaimed.is_empty() || session.doc.repo().log().is_empty() {
279            return;
280        }
281        if let Some(root) = attached_root(session)
282            && self.unclaimed.contains(&root)
283        {
284            self.claim(&root);
285        }
286    }
287
288    /// Pristine cleanup, narrowed to what this session made: a container the
289    /// user opened is theirs however empty it is, and deleting it would not
290    /// be ours to decide. The one still open is left for the sweep on the way
291    /// out, which runs after its store — and its lock — have been dropped.
292    #[cfg(not(target_arch = "wasm32"))]
293    pub(crate) fn sweep_unclaimed(&mut self, session: &Session) {
294        use blockworx_store::container::{Discarded, discard_pristine};
295        use blockworx_store::storage::ready_now;
296        let open = attached_root(session);
297        let mut still_open = Vec::new();
298        for root in std::mem::take(&mut self.unclaimed) {
299            if Some(&root) == open.as_ref() {
300                still_open.push(root);
301                continue;
302            }
303            match ready_now(discard_pristine(&blockworx_store::storage::Native::at(
304                &root,
305            ))) {
306                Ok(Discarded::Removed) => {
307                    tracing::info!("removed {}, which held no edit", root.display());
308                }
309                Ok(Discarded::Kept) => {}
310                Err(e) => tracing::warn!("{} could not be tidied away: {e}", root.display()),
311            }
312        }
313        self.unclaimed = still_open;
314    }
315
316    /// Open the container at `root`, or say why not. A held lock, a broken
317    /// log and a failed write are outcomes rather than failures: the
318    /// container opens read-only and the chrome carries the reason. Only a
319    /// path that is no container — or has become one no longer — costs it
320    /// its place in the recent list.
321    #[cfg(not(target_arch = "wasm32"))]
322    pub(crate) fn open_container(
323        &mut self,
324        session: &mut Session,
325        surface: &mut Surface,
326        root: &Path,
327    ) {
328        // The folder picker takes any folder, so the choice is judged here
329        // rather than by the store's own "it has no log" complaint, which
330        // answers a question the user did not ask.
331        if let Some(refusal) = crate::file::refused_as_a_diagram(root) {
332            session.failures.report(refusal);
333            self.recent.forget(root);
334            return;
335        }
336        match crate::file::open_container(root) {
337            Ok(store) => {
338                if let Some(reason) = store.read_only_reason() {
339                    tracing::warn!("{} opened read-only: {reason}", root.display());
340                }
341                self.recent.remember(root);
342                self.opened = None;
343                self.take_document(session, surface, Doc::attached(store));
344            }
345            Err(refusal) => {
346                session
347                    .failures
348                    .report(format!("Failed to open {}: {refusal}", root.display()));
349                self.recent.forget(root);
350            }
351        }
352    }
353
354    /// Write this session's document into a container at `root` — the one
355    /// save left, since an attached document is written as it is edited.
356    ///
357    /// Two paths, because a session with a log and a session without one are
358    /// different problems. A container's log *is* its document, so `scope`'s
359    /// rev is a cut in its lines and the save copies them: everything the
360    /// records carry — wall times, authors, edit/undo/redo kinds, tags —
361    /// comes over because nothing is rewritten. A scratch session has
362    /// only its commits, so those are seeded, and a cut is a prefix of them.
363    #[cfg(not(target_arch = "wasm32"))]
364    pub(crate) fn save_as_container(
365        &mut self,
366        ctx: &egui::Context,
367        session: &mut Session,
368        root: &Path,
369        scope: crate::file::SaveScope,
370    ) {
371        use crate::file::SaveScope;
372        let written = if let Some(source) = session.doc.container_path().map(Path::to_path_buf) {
373            let at = match scope {
374                SaveScope::Through(at) => at,
375                SaveScope::Whole => session.doc.repo().rev(),
376            };
377            crate::file::save_container_through(&source, at, root, &session.identity)
378                .map_err(|refusal| refusal.to_string())
379        } else {
380            let log = session.doc.repo().log();
381            let through = match scope {
382                SaveScope::Through(at) => at.get() as usize,
383                SaveScope::Whole => log.len(),
384            };
385            let Some(prefix) = log.get(..through) else {
386                // Unreachable through the menu, which reads the rev off the
387                // canvas — and cheaper to answer than to prove.
388                return session
389                    .failures
390                    .report(format!("this session has no rev {through}"));
391            };
392            crate::file::create_container(root, prefix, &session.identity)
393                .map_err(|refusal| refusal.to_string())
394        };
395        match written {
396            Ok(store) => {
397                self.recent.remember(root);
398                self.opened = None;
399                session.adopt(Doc::attached(store));
400                // The document is unchanged — only its home is — so the view
401                // and the selection stay put. The undo stack cannot: what
402                // this session may take back is whatever the new container's
403                // own trail holds, which for a seeded one is nothing (the
404                // commits are its past, not steps anybody took here) and for
405                // a copied log is what replaying it gave back.
406                session.undo_stack =
407                    crate::history::UndoStack::reconstructed(session.doc.trail(), &session.state());
408                // What was on the canvas is now the head of a document this
409                // session may write — which is the whole point of saving
410                // from inside the lens, so the lens closes on it.
411                session.view_head();
412                // A save that worked is routine, so it is said in the
413                // status line rather than raised as an attention event; the
414                // failure below still is one.
415                crate::shell::status_line::say(ctx, saved_as(root, scope));
416            }
417            Err(refusal) => {
418                // Toasted rather than pinned as a notice: nothing about the
419                // session changed, so there is no standing fact to
420                // acknowledge — only news, which is what a toast is for.
421                tracing::error!("Failed to write {}: {refusal}", root.display());
422                crate::shell::toast::say(
423                    ctx,
424                    format!(
425                        "Could not save as {}: {refusal}",
426                        crate::file::document_name(root),
427                    ),
428                );
429            }
430        }
431    }
432
433    /// Deliver a File-menu pick once its off-thread dialog resolves.
434    #[cfg(not(target_arch = "wasm32"))]
435    pub(crate) fn poll_pending_file(
436        &mut self,
437        ctx: &egui::Context,
438        session: &mut Session,
439        surface: &mut Surface,
440    ) {
441        let Some(rx) = &self.pending_file else {
442            return;
443        };
444        match rx.try_recv() {
445            Ok(Some(pick)) => {
446                self.pending_file = None;
447                match pick {
448                    crate::file::FilePick::Container(root) => {
449                        self.open_container(session, surface, &root);
450                    }
451                    crate::file::FilePick::NewContainer(root, scope) => {
452                        self.save_as_container(ctx, session, &root, scope);
453                    }
454                }
455            }
456            // A cancelled dialog and a dropped sender both end the pick.
457            Ok(None) | Err(std::sync::mpsc::TryRecvError::Disconnected) => {
458                self.pending_file = None;
459            }
460            // See `Exchange::poll_pending_import`: a native picker can sit
461            // open for minutes, so poll it gently.
462            Err(std::sync::mpsc::TryRecvError::Empty) => {
463                ctx.request_repaint_after(std::time::Duration::from_millis(100));
464            }
465        }
466    }
467
468    /// The document itself needs nothing written on the way out: every
469    /// commit was durable when it was made. A container's lock is its
470    /// store's lifetime, and eframe does not promise to drop the app on
471    /// every platform, so it is given back here rather than left stale; the
472    /// containers this session left unclaimed go with it.
473    #[cfg(not(target_arch = "wasm32"))]
474    pub(crate) fn on_exit(&mut self, session: &mut Session) {
475        session.doc = Doc::default();
476        self.sweep_unclaimed(session);
477    }
478}
479
480/// The container this session is attached to, if it is.
481#[cfg(not(target_arch = "wasm32"))]
482pub(crate) fn attached_root(session: &Session) -> Option<PathBuf> {
483    session.doc.container_path().map(Path::to_path_buf)
484}
485
486/// A repo whose one commit creates everything `doc` holds, under the ids
487/// the file gave them. The one door a document takes into the editor: the
488/// courtesy open of a plain file comes through here, and a document the fold
489/// refuses opens empty rather than taking the editor down.
490#[cfg(not(target_arch = "wasm32"))]
491fn seeded_repo(doc: &blockworx_doc::document::Document, label: &str) -> Repo {
492    let commits: Vec<_> = doc.creating_commit(label).into_iter().collect();
493    Repo::folding(&commits).unwrap_or_else(|e| {
494        tracing::error!("{label} will not seed a repo: {e}");
495        Repo::default()
496    })
497}
498
499/// What opening a plain document file came back with.
500#[cfg(not(target_arch = "wasm32"))]
501enum Loaded {
502    /// The file opened: this is what it held, under this name, and — for an
503    /// export — where it says it came from. Boxed, so the enum is the size of
504    /// the sentence it carries rather than of a document.
505    Document {
506        repo: Box<Repo>,
507        name: String,
508        from: Option<blockworx_store::stamp::Provenance>,
509    },
510    /// Nothing was there to open, which is not a complaint.
511    Nothing,
512    /// It was there, would not open, and this says why.
513    Failed(String),
514}
515
516/// Whether `path` names a document in a format this build no longer reads.
517/// The reader is gone, so the honest answer is a refusal naming the format —
518/// not a JSON parse failure over a file that was never JSON.
519#[cfg(not(target_arch = "wasm32"))]
520fn names_a_retired_format(path: &Path) -> bool {
521    path.extension()
522        .is_some_and(|ext| ext.eq_ignore_ascii_case("kdl"))
523}
524
525#[cfg(not(target_arch = "wasm32"))]
526fn retired_format_notice(name: &str) -> String {
527    format!("{name} is in the retired KDL document format, which this build no longer reads")
528}
529
530/// A plain document file — an export, or a hand-written one — read into a
531/// fresh repo and named for the window title. Nothing there opens blank
532/// without complaint — a boot invents no content the user never authored; a
533/// read or parse failure comes back as [`Loaded::Failed`] and the session
534/// opens blank around it, since refusing to start helps nobody.
535#[cfg(not(target_arch = "wasm32"))]
536fn open_document_file(path: &Path) -> Loaded {
537    if !path.is_file() {
538        return Loaded::Nothing;
539    }
540    let name = path
541        .file_name()
542        .map_or_else(String::new, |n| n.to_string_lossy().into_owned());
543    if names_a_retired_format(path) {
544        return Loaded::Failed(retired_format_notice(&name));
545    }
546    let src = match std::fs::read_to_string(path) {
547        Ok(src) => src,
548        Err(e) => return Loaded::Failed(format!("Failed to open {}: {e}", path.display())),
549    };
550    match blockworx_store::document_file::parse(&src, &name) {
551        Ok(doc) => Loaded::Document {
552            repo: Box::new(seeded_repo(&doc, &format!("Opened {name}"))),
553            from: blockworx_store::stamp::exported_stamp_in(&src).and_then(|s| s.provenance),
554            name,
555        },
556        Err(e) => {
557            // The console gets the spans and the offending source; the canvas
558            // gets the sentence, since a rendered diagnostic is a wall of text
559            // in a notice.
560            tracing::error!("Failed to open {}:\n{e:?}", path.display());
561            Loaded::Failed(format!("Failed to open {}: {e}", path.display()))
562        }
563    }
564}
565
566/// What the session starts on: the document, the name the window title says
567/// was opened, the container this startup created if it made one, and
568/// whatever would not open on the way — which the canvas says out loud
569/// rather than only the console.
570#[cfg(not(target_arch = "wasm32"))]
571struct Startup {
572    doc: Doc,
573    opened: Option<String>,
574    from: Option<blockworx_store::stamp::Provenance>,
575    born: Option<PathBuf>,
576    failure: Option<String>,
577}
578
579#[cfg(not(target_arch = "wasm32"))]
580impl Startup {
581    /// A session with nothing behind it — the blank canvas the editor opens
582    /// on when there is nowhere to be born.
583    fn detached() -> Self {
584        Startup {
585            doc: Doc::default(),
586            opened: None,
587            from: None,
588            born: None,
589            failure: None,
590        }
591    }
592}
593
594/// What the toast says a Save-as did. Two sentences, because two things
595/// happened: at the head the document has a new home, and under the lens a
596/// *rev* of it does — and which one it was is the thing the user needs to
597/// read back.
598#[cfg(not(target_arch = "wasm32"))]
599fn saved_as(root: &Path, scope: crate::file::SaveScope) -> String {
600    let named = crate::file::document_name(root);
601    match scope {
602        crate::file::SaveScope::Whole => format!("Saved as {named}"),
603        crate::file::SaveScope::Through(at) => {
604            format!("Saved through rev {} as {named}", at.get())
605        }
606    }
607}
608
609/// The born-attached start: a container of its own, so append-on-commit holds
610/// from the first edit. A container that cannot be created is a notice and a
611/// scratch session, never a refusal to start.
612#[cfg(not(target_arch = "wasm32"))]
613fn born_attached(documents: &blockworx_store::naming::Documents) -> Startup {
614    match documents.create(blockworx_store::naming::entropy) {
615        Ok(store) => Startup {
616            born: store.path().map(Path::to_path_buf),
617            doc: Doc::attached(store),
618            opened: None,
619            from: None,
620            failure: None,
621        },
622        Err(failure) => Startup {
623            failure: Some(failure.notice()),
624            ..Startup::detached()
625        },
626    }
627}
628
629/// What a named path opens: a container attached, or — for a plain document
630/// file, and for anything that will not open — a scratch session, which
631/// persists nothing.
632///
633/// The plain-file half is the command line's alone. Nothing in the chrome
634/// offers to open a `document.json`: it is the projection beside a
635/// log, carrying neither that log nor the assets, so opening one would hand
636/// the user half a diagram wearing its name. A developer naming one on the
637/// command line has asked for exactly that, and is told what they got by the
638/// title bar's "[nothing persisted]".
639#[cfg(not(target_arch = "wasm32"))]
640fn open_startup_path(path: &Path) -> Startup {
641    if crate::file::names_an_archive(path) {
642        return match crate::file::open_archive(path) {
643            Ok(store) => Startup {
644                doc: Doc::attached(store),
645                ..Startup::detached()
646            },
647            Err(e) => Startup {
648                failure: Some(format!("Failed to open {}: {e}", path.display())),
649                ..Startup::detached()
650            },
651        };
652    }
653    let mut failure = None;
654    if crate::file::names_a_container(path) {
655        match crate::file::open_container(path) {
656            Ok(store) => {
657                if let Some(reason) = store.read_only_reason() {
658                    tracing::warn!("{} opened read-only: {reason}", path.display());
659                }
660                return Startup {
661                    doc: Doc::attached(store),
662                    ..Startup::detached()
663                };
664            }
665            Err(e) => {
666                failure = Some(format!("Failed to open {}: {e}", path.display()));
667            }
668        }
669    }
670    let (repo, opened, from, load_failure) = match open_document_file(path) {
671        Loaded::Document { repo, name, from } => (*repo, Some(name), from, None),
672        Loaded::Nothing => (Repo::default(), None, None, None),
673        Loaded::Failed(why) => (Repo::default(), None, None, Some(why)),
674    };
675    Startup {
676        doc: Doc::scratch(repo),
677        opened,
678        from,
679        born: None,
680        failure: failure.or(load_failure),
681    }
682}