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}