//! Workspace, file-list and git operations: opening a folder, creating, //! renaming and deleting manuscript files, persisting order and titles, and //! exporting the assembled manuscript to ODT, or — for a screenplay //! project — to a paginated PDF. use super::*; impl App { pub(super) fn workspace(&self) -> &Path { &self.config.workspace } /// The project folder: the root holding the characters, the outline and the /// reference material alongside the draft folder. /// /// In project mode the workspace already *is* that root. Otherwise the /// workspace is a folder of chapters one level down, and the enclosing git /// work tree is the project around it — including one still awaiting the /// user's confirmation, since which folder to show in a file manager is a /// smaller question than which repository to commit to. /// /// Falls back to the workspace when there is no repository, rather than /// guessing at a parent: an unversioned folder of chapters has no project /// around it that we can point to with any confidence. Reads only cached /// state, because the toolbar asks for this every frame. pub(super) fn project_root(&self) -> &Path { project_root_of( &self.config.workspace, self.manuscript_dir.is_some(), self.repo_root.as_deref(), self.pending_repo.as_deref(), ) } /// Show the project folder in the desktop's file manager. pub(super) fn open_project_folder(&mut self) { let path = self.project_root().to_path_buf(); if !path.is_dir() { self.status = format!("No such folder: {}", path.display()); return; } // xdg-open picks whichever file manager the session provides, the same // desktop-portal assumption the native file dialogs already make. let spawned = std::process::Command::new("xdg-open") .arg(&path) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .spawn(); match spawned { Ok(mut child) => { // xdg-open hands off to the file manager and exits immediately. // Reap it off-thread so each click does not leave a zombie behind. std::thread::spawn(move || { let _ = child.wait(); }); self.status = format!("Opened {}", path.display()); } Err(e) => self.status = format!("Could not open {}: {e}", path.display()), } } /// (Re)load the file list for the current workspace, creating the directory /// if needed, and refresh git status. pub(super) fn open_workspace(&mut self) { let ws = self.config.workspace.clone(); if let Err(e) = std::fs::create_dir_all(&ws) { self.status = format!("Cannot create workspace: {e}"); return; } // The project root has moved, so the export follows it. self.retarget_export(); // Which folder is the manuscript has to be settled before the file // list is read, since it decides what counts as a chapter. self.manuscript_dir = self.detect_manuscript_dir(&ws); self.files = order::resolve_order(&ws, &self.config.hidden_folders); self.titles = order::read_titles(&ws); self.wordlist = crate::spell::read_wordlist(&ws); // Drop overrides for files that no longer exist. self.titles.retain(|name, _| self.files.contains(name)); self.detect_repo(&ws); self.selected = None; self.buffer.clear(); self.header_stash = None; self.dirty = false; self.pending_delete = false; self.clear_lt(); self.dismissed.clear(); self.session_start_counts = self.snapshot_counts(); self.file_meta = self.snapshot_file_meta(); self.rebuild_field_names(); self.load_characters(); self.autocomplete = None; self.collapsed.clear(); if let Some(idx) = first_listed(&self.files, self.manuscript_dir.as_deref()) { self.select(idx); } self.persist_order(); self.status = match &self.manuscript_dir { Some(dir) => { let chapters = self.manuscript_files().len(); format!( "{chapters} chapter(s) in {dir}, {} reference file(s) — {}", self.files.len() - chapters, ws.display() ) } None => format!("{} file(s) in {}", self.files.len(), ws.display()), }; } /// Point the export at the opened workspace, which is the project root. /// /// A file name the user picked deliberately is carried across; one that /// merely echoed the previous project's folder is re-derived, so exports /// land beside the book being worked on instead of piling up in the /// project left behind. pub(super) fn retarget_export(&mut self) { let path = export_path_for(&self.config.workspace, &self.config.export_path); if path != self.config.export_path { self.config.export_path = path; self.config.save(); } self.export_input = self.config.export_path.display().to_string(); } /// Work out the git situation for a freshly opened workspace. /// /// If the folder is itself a repository root it is adopted silently. If it /// is not, but an enclosing parent directory is a git work tree, we stash /// that parent in `pending_repo` and ask the user to confirm before using /// it (see [`repo_prompt`](Self::repo_prompt)). Otherwise the workspace is /// treated as having no repository. pub(super) fn detect_repo(&mut self, ws: &Path) { self.pending_repo = None; // A `.git` entry directly in the folder (dir, or a file for linked // worktrees/submodules) means this folder is the repo root. if ws.join(".git").exists() { self.is_repo = true; self.repo_root = Some(ws.to_path_buf()); self.remote_input = gitsync::origin_url(ws).unwrap_or_default(); return; } match gitsync::repo_root(ws) { // A parent directory is a repository — ask before adopting it. Some(root) if root != ws => { self.is_repo = false; self.repo_root = None; self.pending_repo = Some(root); } // `--show-toplevel` reported this very folder (shouldn't happen // without a `.git` here, but treat it as an ordinary repo root). Some(root) => { self.is_repo = true; self.repo_root = Some(root); self.remote_input = gitsync::origin_url(ws).unwrap_or_default(); } None => { self.is_repo = false; self.repo_root = None; self.remote_input.clear(); } } } /// Adopt the parent repository awaiting confirmation, using it for all git /// operations on the current workspace. pub(super) fn adopt_pending_repo(&mut self) { if let Some(root) = self.pending_repo.take() { self.status = format!("Using git repository at {}", root.display()); self.is_repo = true; self.repo_root = Some(root); self.remote_input = gitsync::origin_url(self.workspace()).unwrap_or_default(); } } /// Decline the parent repository; the workspace stays without version /// control (the user can still `Init git` to create a nested repo). pub(super) fn decline_pending_repo(&mut self) { self.pending_repo = None; self.is_repo = false; self.repo_root = None; self.status = "Not using the enclosing git repository".to_string(); } /// Work out whether the opened folder is a **project root** — a folder /// holding the manuscript alongside characters, outline and the rest — and /// if so, which of its subfolders is the manuscript. /// /// The test is simply whether it has a child folder named like the /// configured manuscript folder (`06-First Draft`), matched leniently by /// [`order::dir_matches`]. `None` means the workspace is itself the /// manuscript, which is how the app behaved before project mode and remains /// the right answer for a plain folder of chapters. pub(super) fn detect_manuscript_dir(&self, ws: &Path) -> Option { order::child_dir_matching(ws, self.config.project_open_subdir.trim()) } /// Whether `path` (workspace-relative) is part of the manuscript proper: /// ordered, numbered and exported. Everything else in a project — character /// sheets, outline, scratch pad — is reference material that the app will /// happily open and edit but leaves out of the book. pub(super) fn is_manuscript(&self, path: &str) -> bool { match &self.manuscript_dir { Some(dir) => is_within(path, dir), None => true, } } /// The manuscript's files, in order, paired with their chapter index. pub(super) fn manuscript_files(&self) -> Vec<(usize, &String)> { self.files .iter() .filter(|path| self.is_manuscript(path)) .enumerate() .collect() } /// Read every file and return its current on-disk word count. pub(super) fn snapshot_counts(&self) -> HashMap { self.files .iter() .map(|name| { let text = std::fs::read_to_string(self.path_for(name)).unwrap_or_default(); (name.clone(), count_words(&text)) }) .collect() } /// Read every file and return its cached header info (slug/POV/goal/status /// and prose length). Every file is cached, not only the ones with a tooltip /// to show, because the status column and the project word total need a /// figure for each. pub(super) fn snapshot_file_meta(&self) -> HashMap { self.files .iter() .map(|name| { let text = std::fs::read_to_string(self.path_for(name)).unwrap_or_default(); ( name.clone(), FileMeta::from_markdown(&text, &self.config.draft_marker), ) }) .collect() } /// Prose words across the whole manuscript, counting the open file from the /// buffer so the total moves as you type. pub(super) fn manuscript_words(&self) -> usize { let open = self.selected.and_then(|i| self.files.get(i)); self.manuscript_files() .into_iter() .map(|(_, name)| { if Some(name) == open { let h = crate::preprocess::parse(&self.document(), &self.config.draft_marker); count_words(&h.body) } else { self.file_meta.get(name).map_or(0, |m| m.prose_words) } }) .sum() } /// Every distinct `Status:` value in the manuscript, for the filter menu. pub(super) fn known_statuses(&self) -> Vec { let mut seen: Vec = Vec::new(); for name in self.files.iter() { let Some(status) = self.file_meta.get(name).and_then(|m| m.status.clone()) else { continue; }; if !seen.iter().any(|s| s.eq_ignore_ascii_case(&status)) { seen.push(status); } } seen.sort_by_key(|s| s.to_lowercase()); seen } pub(super) fn persist_order(&self) { let _ = order::write_order(self.workspace(), &self.files); } pub(super) fn persist_titles(&self) { let _ = order::write_titles(self.workspace(), &self.titles); } /// Store the title override for the selected file from `title_input`. /// An empty value removes the override (falls back to the auto title). pub(super) fn set_title_for_current(&mut self) { if let Some(idx) = self.selected { if let Some(name) = self.files.get(idx).cloned() { let trimmed = self.title_input.trim(); if trimmed.is_empty() { self.titles.remove(&name); } else { self.titles.insert(name, trimmed.to_string()); } self.persist_titles(); } } } pub(super) fn path_for(&self, name: &str) -> PathBuf { self.workspace().join(name) } // ---- Collapsing the editorial header ------------------------------------ /// The whole document: the editor's text with the collapsed header, if any, /// put back in front of it. /// /// While the header is collapsed it is not in `buffer` at all, which is what /// keeps every byte offset the editor works in — search matches, spelling /// underlines, the caret — pointing at what is actually on screen. Anything /// that wants the *file* rather than the view asks for this instead. pub(super) fn document(&self) -> std::borrow::Cow<'_, str> { match &self.header_stash { Some(header) => std::borrow::Cow::Owned(format!("{header}{}", self.buffer)), None => std::borrow::Cow::Borrowed(&self.buffer), } } /// Lift the header out of the buffer, leaving the prose. Does nothing for a /// document with no draft marker — there is nothing to collapse. pub(super) fn collapse_header(&mut self) { if self.header_stash.is_some() { return; } let Some((header, body)) = crate::preprocess::split_at_marker(&self.buffer, &self.config.draft_marker) else { return; }; let (header, body) = (header.to_string(), body.to_string()); self.header_stash = Some(header); self.buffer = body; self.after_view_change(); } /// Put the header back into the buffer. pub(super) fn expand_header(&mut self) { let Some(header) = self.header_stash.take() else { return; }; self.buffer.insert_str(0, &header); self.after_view_change(); } /// Re-collapse or expand to match the setting, after the buffer is replaced. pub(super) fn apply_header_collapse(&mut self) { if self.config.collapse_header { self.collapse_header(); } else { self.expand_header(); } } /// Everything keyed to the buffer's contents has to be recomputed when the /// header moves in or out of it — but the file itself has not changed, so /// this must not mark the document dirty. fn after_view_change(&mut self) { self.find_matches.clear(); self.find_active = 0; self.find_needs_refresh = true; self.spell_matches.clear(); self.spell_checked_text.clear(); self.spell_dirty = true; self.spell_last_edit = None; self.spell_menu = None; self.clear_lt(); self.autocomplete = None; } /// How many header fields are hidden right now, for the toggle's label. pub(super) fn hidden_field_count(&self) -> usize { match &self.header_stash { Some(header) => crate::preprocess::fields(header, "").len(), None => 0, } } /// Save the in-memory buffer to disk if it has unsaved changes. pub(super) fn save_current(&mut self) { if let Some(idx) = self.selected { if self.dirty { if let Some(name) = self.files.get(idx).cloned() { let path = self.path_for(&name); match std::fs::write(&path, self.document().as_bytes()) { Ok(_) => { self.dirty = false; self.status = format!("Saved {name}"); // Keep the file-list cache (slug/POV/goal/prose) in step. let meta = FileMeta::from_markdown( &self.document(), &self.config.draft_marker, ); self.file_meta.insert(name, meta); self.merge_field_names_from_buffer(); } Err(e) => self.status = format!("Save failed: {e}"), } } } } } pub(super) fn select(&mut self, idx: usize) { if self.selected == Some(idx) { return; } self.save_current(); self.clear_lt(); // Matches from the previous file's buffer are meaningless now. self.find_matches.clear(); self.find_active = 0; self.find_needs_refresh = true; // Re-run the live spell check on the newly loaded buffer immediately. self.spell_matches.clear(); self.spell_checked_text.clear(); self.spell_dirty = true; self.spell_last_edit = None; self.spell_menu = None; // Dismissals are about this file's sentences, not the next one's. self.dismissed.clear(); let Some(name) = self.files.get(idx).cloned() else { return; }; let path = self.path_for(&name); self.header_stash = None; self.buffer = std::fs::read_to_string(&path).unwrap_or_default(); self.apply_header_collapse(); self.selected = Some(idx); self.dirty = false; self.pending_delete = false; // The rename box holds the whole workspace-relative path, so it doubles // as the way to move a file between folders by typing. self.rename_input = strip_md(&name).to_string(); self.title_input = self.titles.get(&name).cloned().unwrap_or_default(); self.reveal(&name); } /// Expand any collapsed folders that would hide `path`, so a file that was /// just selected or created is actually on screen. pub(super) fn reveal(&mut self, path: &str) { self.collapsed.retain(|dir| !is_within(path, dir)); } /// Move one manuscript file on disk and carry its per-file state — title /// override, session word baseline, cached header info — across to the new /// path. The caller is responsible for updating `files`; on error nothing /// has changed. fn relocate(&mut self, old: &str, new: &str) -> std::io::Result<()> { move_manuscript_file(self.workspace(), old, new)?; if let Some(title) = self.titles.remove(old) { self.titles.insert(new.to_string(), title); self.persist_titles(); } if let Some(words) = self.session_start_counts.remove(old) { self.session_start_counts.insert(new.to_string(), words); } if let Some(meta) = self.file_meta.remove(old) { self.file_meta.insert(new.to_string(), meta); } Ok(()) } pub(super) fn create_file(&mut self) { // The typed name may carry folders (`part-1/ch-01`), which is the only // way new folders come into being — one appears with its first file. let Some(name) = sanitize_rel_path(&self.new_name) else { self.status = "Enter a name for the new file".to_string(); return; }; let stem = strip_md(base_name(&name)).to_string(); let seed = format!("# {stem}\n\n"); self.insert_new_file(name, seed); } /// Create a file seeded from the configured template, naming it /// `untitled-N.md` so the button works without typing a name first. It lands /// beside the file being edited, so working inside a part folder doesn't /// scatter untitled files back at the workspace root. pub(super) fn create_file_from_template(&mut self) { let dir = self .selected .and_then(|i| self.files.get(i)) .map(|path| parent_dir(path).to_string()) .unwrap_or_default(); let existing: std::collections::HashSet = self.files.iter().map(|n| n.to_lowercase()).collect(); let leaf = next_untitled_name(|candidate| { let full = join_rel(&dir, candidate); existing.contains(&full.to_lowercase()) || self.path_for(&full).exists() }); let stem = strip_md(&leaf).to_string(); let seed = render_template( &self.config.effective_new_file_template(), &stem, self.config.draft_marker.trim(), &today_utc(), ); self.insert_new_file(join_rel(&dir, &leaf), seed); // The template's header fields should be offerable straight away. self.rebuild_field_names(); } /// Write `contents` to a new file, add it to the manuscript order and select /// it. Shared by the plain and template-backed create paths. fn insert_new_file(&mut self, name: String, contents: String) { let path = self.path_for(&name); if path.exists() { self.status = format!("{name} already exists"); return; } if let Some(parent) = path.parent() { if let Err(e) = std::fs::create_dir_all(parent) { self.status = format!("Create failed: {e}"); return; } } match std::fs::write(&path, contents) { Ok(_) => { self.files.push(name.clone()); // Folder-tree order decides where the newcomer actually lands, // so normalise before working out which index to select. self.files = order::tree_order(&self.files); self.persist_order(); let idx = self.files.iter().position(|f| *f == name).unwrap_or(0); self.selected = None; // force reload of buffer self.select(idx); self.new_name.clear(); self.status = format!("Created {name}"); } Err(e) => self.status = format!("Create failed: {e}"), } } pub(super) fn delete_selected(&mut self) { if let Some(idx) = self.selected { if let Some(name) = self.files.get(idx).cloned() { let path = self.path_for(&name); match std::fs::remove_file(&path) { Ok(_) => { self.files.remove(idx); self.titles.remove(&name); self.session_start_counts.remove(&name); self.file_meta.remove(&name); self.persist_titles(); self.persist_order(); // The folder may have held nothing else. prune_empty_dirs(self.workspace(), parent_dir(&name)); self.selected = None; self.buffer.clear(); self.header_stash = None; self.dirty = false; if let Some(next) = nearest_listed(&self.files, self.manuscript_dir.as_deref(), idx) { self.select(next); } self.status = format!("Deleted {name}"); } Err(e) => self.status = format!("Delete failed: {e}"), } } } self.pending_delete = false; } /// Move the selected file into the project's archive folder: off the /// manuscript, out of the panel, but still on disk. /// /// Deleting is destructive and a superseded scene is often worth keeping; /// the archive folder is hidden from the scan, so archiving a file makes it /// disappear from the tree without losing it. pub(super) fn archive_selected(&mut self) { let Some(idx) = self.selected else { return }; let Some(name) = self.files.get(idx).cloned() else { return; }; let archive = self.archive_dir(); // Keep the file's shape inside the archive, so a scene from // `Act 1/` lands in `10-Archive/Act 1/` rather than losing its place. let dest = join_rel(&archive, &name); if self.path_for(&dest).exists() { self.status = format!("{dest} already exists"); return; } self.save_current(); if let Err(e) = self.relocate(&name, &dest) { self.status = format!("Archive failed: {e}"); return; } self.files.remove(idx); self.persist_order(); self.selected = None; self.buffer.clear(); self.header_stash = None; self.dirty = false; if let Some(next) = nearest_listed(&self.files, self.manuscript_dir.as_deref(), idx) { self.select(next); } self.status = format!("Archived {name} to {archive}"); } /// The archive folder's name within the workspace: whatever the project /// already calls it, otherwise the configured default. pub(super) fn archive_dir(&self) -> String { let configured = self.config.archive_folder.trim(); let role = if configured.is_empty() { "Archive" } else { configured }; order::child_dir_matching(self.workspace(), role).unwrap_or_else(|| role.to_string()) } /// Rename the selected file to whatever `rename_input` holds, reporting /// whether it went through so a caller showing a dialog knows to keep it /// open (and the typed name with it) when the name is unusable. pub(super) fn rename_selected(&mut self) -> bool { let Some(idx) = self.selected else { return false }; // A path in the box (`part-2/ch-07`) both renames and moves the file. let Some(new_name) = sanitize_rel_path(&self.rename_input) else { self.status = "Enter a new name".to_string(); return false; }; let Some(old_name) = self.files.get(idx).cloned() else { return false; }; if new_name == old_name { // Already what was asked for, so the caller can close. return true; } if self.path_for(&new_name).exists() { self.status = format!("{new_name} already exists"); return false; } // Persist any pending edits under the old name first. self.save_current(); match self.relocate(&old_name, &new_name) { Ok(_) => { self.files[idx] = new_name.clone(); self.files = order::tree_order(&self.files); self.selected = self.files.iter().position(|f| *f == new_name); self.persist_order(); self.reveal(&new_name); self.status = format!("Renamed to {new_name}"); true } Err(e) => { self.status = format!("Rename failed: {e}"); false } } } pub(super) fn git_init(&mut self) { // Initialising creates a repository in the workspace itself, which // supersedes any enclosing repo we were about to ask about. self.pending_repo = None; let ws = self.workspace().to_path_buf(); let outcome = gitsync::init(&ws); self.git_log = outcome.log; self.show_log = true; self.is_repo = gitsync::is_repo(&ws); self.remote_input = gitsync::origin_url(&ws).unwrap_or_default(); self.repo_root = self.is_repo.then_some(ws); self.status = if self.is_repo { "Initialised git repository".to_string() } else { "git init failed (see log)".to_string() }; } pub(super) fn git_sync(&mut self) { self.save_current(); self.persist_order(); self.persist_titles(); let msg = format!( "Sync manuscript {}", chrono_like_timestamp() ); let outcome = gitsync::sync(self.workspace(), &msg); self.git_log = outcome.log; self.show_log = true; self.status = if outcome.ok { "Sync complete".to_string() } else { "Sync finished with errors (see log)".to_string() }; } /// Digits to zero-pad a defaulted chapter number to: the width of the largest /// chapter number when padding is enabled, otherwise 1 (no padding). pub(super) fn index_pad_width(&self) -> usize { if self.config.zero_pad_index { self.manuscript_files().len().to_string().len().max(1) } else { 1 } } /// The title a chapter at `idx` would get without a manual override: its /// `# Title:` header, or its 1-based position (e.g. "3."). Shown as the hint /// in the chapter-title field. pub(super) fn auto_title(&self, idx: usize, markdown: &str) -> String { let header = crate::preprocess::parse(markdown, &self.config.draft_marker); resolve_chapter_title(None, header.title.as_deref(), idx, self.index_pad_width()) } /// The current file's word-count target (from its `Word Count Target:` /// header) paired with its current prose word count, if a target is set. /// Prose = the body below the draft marker, so header metadata isn't counted. pub(super) fn current_goal(&self) -> Option<(crate::preprocess::WordGoal, usize)> { self.selected?; let header = crate::preprocess::parse(&self.document(), &self.config.draft_marker); let goal = header.goal?; Some((goal, count_words(&header.body))) } /// Document properties for an export: the configured title, author and /// contact details, with the title falling back to the project folder's own /// name. pub(super) fn doc_meta(&self, chapters: &[Chapter]) -> odt::DocMeta { let title = match self.config.manuscript_title.trim() { "" => self .workspace() .file_name() .and_then(|n| n.to_str()) .unwrap_or("Manuscript") .to_string(), set => set.to_string(), }; odt::DocMeta { title, author: self.config.manuscript_author.trim().to_string(), contact: self.config.manuscript_contact.clone(), title_page: self.config.manuscript_title_page, standard_format: self.config.manuscript_standard_format, subject: String::new(), keywords: String::new(), word_count: chapters.iter().map(|c| count_words(&c.markdown)).sum(), chapter_count: chapters.len(), } } /// Export the manuscript as one `.odt` per chapter plus an `.odm` master /// that links them, which is the shape the snowflake template expects. /// /// The per-chapter files are ordinary documents and are the useful part. /// The master is a shell: LibreOffice does not follow its section links on /// load without being asked to update them, so treat it as a starting point /// rather than as the assembled book. pub(super) fn export_master(&mut self) { self.save_current(); let chapters = self.collect_chapters(); if chapters.is_empty() { self.status = "Nothing to export — the manuscript is empty".to_string(); return; } let meta = self.doc_meta(&chapters); let out = PathBuf::from(self.export_input.trim()).with_extension("odm"); if let Some(parent) = out.parent() { let _ = std::fs::create_dir_all(parent); } match odt::export_master(&chapters, &meta, &out) { Ok(written) => { self.status = format!( "Wrote {} chapter file(s) beside {}", written.len(), out.display() ); } Err(e) => self.status = format!("Export failed: {e}"), } } /// The manuscript as chapters, ready for either export path. fn collect_chapters(&self) -> Vec { let marker = self.config.draft_marker.clone(); let pad_width = self.index_pad_width(); self.manuscript_files() .into_iter() .map(|(i, name)| { let raw = std::fs::read_to_string(self.path_for(name)).unwrap_or_default(); let header = crate::preprocess::parse(&raw, &marker); Chapter { title: resolve_chapter_title( self.titles.get(name).map(String::as_str), header.title.as_deref(), i, pad_width, ), slug: header.slug.clone(), markdown: header.body, } }) .collect() } /// Details for a screenplay export. The same Manuscript details a prose /// export uses: a screenplay project differs in how its pages are set, not /// in who wrote it or how to reach them. pub(super) fn screenplay_meta(&self) -> crate::screenplay::ScreenplayMeta { crate::screenplay::ScreenplayMeta { title: match self.config.manuscript_title.trim() { "" => self .workspace() .file_name() .and_then(|n| n.to_str()) .unwrap_or("Screenplay") .to_string(), set => set.to_string(), }, author: self.config.manuscript_author.trim().to_string(), contact: self.config.manuscript_contact.clone(), title_page: self.config.manuscript_title_page, } } /// The manuscript's files as Fountain bodies, in order. /// /// A screenplay project keeps the same editorial header as a prose one, so /// the header is stripped exactly as it is for an ODT export; what is left /// below the draft marker is the Fountain. fn collect_bodies(&self) -> Vec { let marker = self.config.draft_marker.clone(); self.manuscript_files() .into_iter() .map(|(_, name)| { let raw = std::fs::read_to_string(self.path_for(name)).unwrap_or_default(); crate::preprocess::parse(&raw, &marker).body }) .collect() } /// Export the manuscript the way this project wants exporting: a screenplay /// as a paginated PDF, anything else as an ODT. pub(super) fn export_manuscript(&mut self) { if self.config.manuscript_screenplay { self.export_screenplay(); } else { self.export_odt(); } } /// What the export button says and does, which depends on the project. pub(super) fn export_label(&self) -> &'static str { if self.config.manuscript_screenplay { "Export PDF" } else { "Export ODT" } } /// Lay the manuscript out as a screenplay and write it as a PDF beside /// wherever the export path points. /// /// The extension is forced rather than taken from the box, the same way the /// master export forces `.odm`: the path is remembered across projects and /// would otherwise still be naming last project's `.odt`. pub(super) fn export_screenplay(&mut self) { self.save_current(); let bodies = self.collect_bodies(); if bodies.iter().all(|b| b.trim().is_empty()) { self.status = "Nothing to export — the screenplay is empty".to_string(); return; } let out = PathBuf::from(self.export_input.trim()).with_extension("pdf"); let meta = self.screenplay_meta(); match crate::screenplay::export(&bodies, &meta, &out) { Ok(pages) => { self.config.export_path = out.clone(); self.config.save(); self.export_input = out.display().to_string(); self.status = format!( "Exported {} page(s) of screenplay to {}", pages, out.display() ); } Err(e) => self.status = format!("Export failed: {e}"), } } pub(super) fn export_odt(&mut self) { self.save_current(); let marker = self.config.draft_marker.clone(); let pad_width = self.index_pad_width(); let mut chapters = Vec::new(); for (i, name) in self.manuscript_files() { let raw = std::fs::read_to_string(self.path_for(name)).unwrap_or_default(); // Strip comments + the editorial header, and lift out any // `# Title:` / `# Slug:` metadata. let header = crate::preprocess::parse(&raw, &marker); // Title priority: manual override > `# Title:` metadata > the chapter's // 1-based position (e.g. "3."). The body from `parse` is used as-is. let title = resolve_chapter_title( self.titles.get(name).map(String::as_str), header.title.as_deref(), i, pad_width, ); chapters.push(Chapter { title, slug: header.slug.clone(), markdown: header.body, }); } let out = PathBuf::from(self.export_input.trim()); if let Some(parent) = out.parent() { let _ = std::fs::create_dir_all(parent); } let meta = self.doc_meta(&chapters); match odt::export(&chapters, &meta, &out) { Ok(_) => { self.config.export_path = out.clone(); self.config.save(); self.status = format!("Exported {} chapter(s) to {}", chapters.len(), out.display()); } Err(e) => self.status = format!("Export failed: {e}"), } } /// Open a native folder picker to choose the workspace directory. pub(super) fn browse_workspace(&mut self) { let start = PathBuf::from(self.workspace_input.trim()); let mut dialog = rfd::FileDialog::new().set_title("Choose workspace folder"); if start.is_dir() { dialog = dialog.set_directory(&start); } if let Some(path) = dialog.pick_folder() { self.save_current(); self.workspace_input = path.display().to_string(); self.config.workspace = path; self.config.save(); self.open_workspace(); } } /// Open a native save dialog to choose the export `.odt` path. pub(super) fn browse_export(&mut self) { let current = PathBuf::from(self.export_input.trim()); let screenplay = self.config.manuscript_screenplay; let (label, ext) = if screenplay { ("PDF", "pdf") } else { ("OpenDocument Text", "odt") }; let mut dialog = rfd::FileDialog::new() .set_title("Choose export file") .add_filter(label, &[ext]); if let Some(parent) = current.parent().filter(|p| p.is_dir()) { dialog = dialog.set_directory(parent); } let fallback = format!("manuscript.{ext}"); let name = current .file_name() .and_then(|s| s.to_str()) .unwrap_or(&fallback); if let Some(mut path) = dialog.set_file_name(name).save_file() { // Ensure the chosen path carries the extension even if it was // omitted, or was left over from the other kind of export. let matches = path .extension() .and_then(|e| e.to_str()) .is_some_and(|e| e.eq_ignore_ascii_case(ext)); if !matches { path.set_extension(ext); } self.export_input = path.display().to_string(); self.config.export_path = path; self.config.save(); } } /// Apply a file-panel drag-and-drop: move the file into the drop's folder on /// disk when that changed, then reposition it in the manuscript order. pub(super) fn apply_drop(&mut self, drop: FileDrop) { let FileDrop { from, to, dir } = drop; let Some(old) = self.files.get(from).cloned() else { return; }; if parent_dir(&old) == dir { self.status = "Reordered".to_string(); } else { let new = join_rel(&dir, base_name(&old)); if self.path_for(&new).exists() { self.status = format!("{new} already exists"); return; } // Flush pending edits under the old name before the file moves. self.save_current(); if let Err(e) = self.relocate(&old, &new) { self.status = format!("Move failed: {e}"); return; } self.files[from] = new; self.status = match dir.as_str() { "" => format!("Moved {} to the workspace root", base_name(&old)), dir => format!("Moved {} into {dir}", base_name(&old)), }; } self.reorder(from, to); } /// Move the file at `from` to flat position `to`, then re-normalise into /// folder-tree order so the panel and the export stay in step. pub(super) fn reorder(&mut self, from: usize, mut to: usize) { if from >= self.files.len() { return; } // Remember the selected file by name so selection follows the move. let selected_name = self.selected.and_then(|i| self.files.get(i)).cloned(); let item = self.files.remove(from); if from < to { to -= 1; } to = to.min(self.files.len()); self.files.insert(to, item); self.files = order::tree_order(&self.files); self.persist_order(); if let Some(name) = selected_name { self.selected = self.files.iter().position(|n| *n == name); } } /// Settings dialog for the markdown seeded into template-backed new files. /// Open the git-remote dialog, reading the current `origin` fresh so the /// box shows what the repository actually holds. pub(super) fn open_git_remote(&mut self) { self.remote_input = gitsync::origin_url(self.workspace()).unwrap_or_default(); if self.gitea_repo_name.trim().is_empty() { // The project folder's name is nearly always what the repository // should be called, and it is the tedious part to type. self.gitea_repo_name = self .project_root() .file_name() .map(|n| crate::gitea::normalize_name(&n.to_string_lossy())) .unwrap_or_default(); } self.gitea_status.clear(); self.show_git_remote = true; } /// Ask Gitea for the repository named in the dialog, on a worker thread. /// /// The call goes over the network, so it cannot happen on the UI thread; /// [`App::poll_gitea_create`] picks the answer up. While one is in flight /// `gitea_rx` is `Some`, which is also what greys the button out. fn start_gitea_create(&mut self, ctx: &egui::Context) { if self.gitea_rx.is_some() { return; } let server = self.config.gitea_url.clone(); let user = self.config.gitea_user.clone(); let token = self.config.gitea_token.clone(); let repo = crate::gitea::NewRepo { name: self.gitea_repo_name.clone(), description: String::new(), private: self.config.gitea_private, }; let (tx, rx) = std::sync::mpsc::channel(); self.gitea_rx = Some(rx); self.gitea_status = "Asking Gitea\u{2026}".to_string(); let ctx = ctx.clone(); std::thread::spawn(move || { let _ = tx.send(crate::gitea::create(&server, &user, &token, &repo)); ctx.request_repaint(); }); } /// Pick up a finished Gitea call and point `origin` at what came back. /// /// Creating the repository and attaching to it are one action as far as the /// reader is concerned, so the remote is set here rather than left as a /// second button to remember. Pushing is still [`App::git_sync`]'s job — /// this only makes it possible. pub(super) fn poll_gitea_create(&mut self) { let received = self.gitea_rx.as_ref().and_then(|rx| rx.try_recv().ok()); let Some(result) = received else { return }; self.gitea_rx = None; match result { Ok(repo) => { self.remote_input = repo.remote_url(self.config.gitea_use_ssh).to_string(); self.set_git_remote(); let verb = if repo.created { "Created" } else { "Already had" }; self.gitea_status = format!( "\u{2714} {verb} {} \u{2014} origin set. Press \u{27f3} Sync (git) to push.", repo.full_name ); } Err(e) => self.gitea_status = format!("\u{2716} {e}"), } } /// Point the workspace's repository at the typed remote. /// /// Nothing about the URL is kept in this app's config: it is written to the /// repository and read straight back, so what the box shows afterwards is /// what git holds, and a different project shows its own. pub(super) fn set_git_remote(&mut self) { let ws = self.workspace().to_path_buf(); if !self.is_repo { self.status = "Not a git repository yet \u{2014} use \u{201c}Init git\u{201d} first" .to_string(); return; } let outcome = gitsync::set_origin(&ws, &self.remote_input); self.git_log = outcome.log; self.show_log = true; self.remote_input = gitsync::origin_url(&ws).unwrap_or_default(); self.status = if !outcome.ok { "Could not set the remote (see log)".to_string() } else if self.remote_input.is_empty() { "Removed the origin remote".to_string() } else { format!("origin is now {}", self.remote_input) }; } /// The dialog for attaching a workspace to an existing remote repository. /// /// Creating the repository on the server is a template hook's job, and only /// happens for projects made through New project. Anything else — a /// workspace that predates the app, or one whose hook did not run — needs /// the remote set by hand, which otherwise meant leaving for a terminal. pub(super) fn git_remote_window(&mut self, ctx: &egui::Context) { let mut open = self.show_git_remote; let mut apply = false; let mut close = false; let mut create = false; let mut save_now = false; let is_repo = self.is_repo; let busy = self.gitea_rx.is_some(); // Read out before the closure borrows `self` mutably to draw with. let server = self.config.gitea_url.trim().to_string(); let user = self.config.gitea_user.trim().to_string(); let have_token = !self.config.gitea_token.trim().is_empty(); let root = self .repo_root .as_ref() .map(|r| r.display().to_string()) .unwrap_or_default(); egui::Window::new("Git remote") .open(&mut open) .resizable(false) .collapsible(false) .default_width(460.0) .show(ctx, |ui| { if !is_repo { ui.label( egui::RichText::new( "This workspace is not a git repository yet. Use \u{201c}Init \ git\u{201d} in the toolbar first, then set the remote here.", ) .small() .weak(), ); ui.separator(); } ui.label(egui::RichText::new(format!("Repository: {root}")).small().weak()); ui.add_space(4.0); ui.label("origin:"); let r = ui.add( egui::TextEdit::singleline(&mut self.remote_input) .desired_width(f32::INFINITY) .hint_text("ssh://git@host:port/user/repo.git"), ); apply |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); ui.label( egui::RichText::new( "The repository has to exist on the server already \u{2014} this \ attaches to it, it does not create it. Until origin is set, \ \u{201c}\u{27f3} Sync (git)\u{201d} commits locally and says so in \ the log. Clear the box and press Set to detach.", ) .small() .weak(), ); ui.add_space(4.0); ui.label( egui::RichText::new( "Stored in the repository, not in this app's settings, so each \ project keeps its own.", ) .small() .weak(), ); ui.horizontal(|ui| { apply |= ui.add_enabled(is_repo, egui::Button::new("Set origin")).clicked(); }); ui.separator(); ui.label(egui::RichText::new("Create it on Gitea").strong()); if server.is_empty() || !have_token { ui.label( egui::RichText::new( "Set the Gitea server, user and token in Settings \u{25b8} New \ project first \u{2014} the app uses them for this too.", ) .small() .weak(), ); } else { ui.label( egui::RichText::new(format!("{server} as {user}")) .small() .weak(), ); } ui.add_space(4.0); ui.horizontal(|ui| { ui.label("Name:"); ui.add( egui::TextEdit::singleline(&mut self.gitea_repo_name) .desired_width(200.0) .hint_text("my-book"), ); save_now |= ui.checkbox(&mut self.config.gitea_private, "Private").changed(); }); save_now |= ui .checkbox(&mut self.config.gitea_use_ssh, "Attach over SSH") .on_hover_text( "SSH pushes on a key. Turn this off to use the HTTPS clone \ URL, which asks for a credential on every push.", ) .changed(); ui.add_space(4.0); ui.horizontal(|ui| { let ready = is_repo && !server.is_empty() && have_token && !busy; create |= ui .add_enabled(ready, egui::Button::new("Create and attach")) .on_hover_text( "Create the repository on Gitea and point origin at it. \ A name that already exists is attached to rather than \ treated as an error.", ) .clicked(); if busy { ui.spinner(); } }); if !self.gitea_status.is_empty() { ui.label(egui::RichText::new(&self.gitea_status).small()); } ui.separator(); if ui.button("Close").clicked() { close = true; } if save_now { self.config.save(); } }); if apply { self.set_git_remote(); } if create { self.start_gitea_create(ctx); } self.show_git_remote = open && !close; } /// Carry out what a file row's right-click menu picked. /// /// Right-clicking acts on the row you clicked, so that row becomes the /// selection first — every operation here works from `selected`, and /// [`App::select`] is what primes `rename_input` with the current path. /// Selecting is a no-op when the row is already current, so this never /// reloads the buffer out from under an edit. pub(super) fn apply_file_action(&mut self, action: FileAction, ctx: &egui::Context) { self.select(action.index()); match action { FileAction::Rename(_) => { self.show_rename = true; self.rename_focus = true; } FileAction::CopyPath(idx) => { if let Some(name) = self.files.get(idx) { ctx.copy_text(name.clone()); self.status = format!("Copied {name}"); } } FileAction::Archive(_) => self.archive_selected(), FileAction::Delete(_) => self.confirm_delete = true, } } /// The rename dialog opened from a file row's context menu. It edits the /// same `rename_input` as the box under the list, so the two stay in step. pub(super) fn rename_window(&mut self, ctx: &egui::Context) { let Some(current) = self.selected.and_then(|i| self.files.get(i)).cloned() else { // The file went away underneath us (deleted, or the workspace // changed); there is nothing left to rename. self.show_rename = false; return; }; let mut open = self.show_rename; let mut submit = false; let mut cancel = false; egui::Window::new("Rename file") .open(&mut open) .resizable(false) .collapsible(false) .default_width(320.0) .show(ctx, |ui| { ui.label(egui::RichText::new(current.as_str()).small().weak()); ui.add_space(4.0); let r = ui.add( egui::TextEdit::singleline(&mut self.rename_input) .desired_width(f32::INFINITY) .hint_text("name, or a path to move it"), ); if self.rename_focus { r.request_focus(); self.rename_focus = false; } submit |= r.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); ui.label( egui::RichText::new( "The path within the workspace \u{2014} edit the folder part to \ move the file. The .md extension is added for you.", ) .small() .weak(), ); ui.separator(); ui.horizontal(|ui| { submit |= ui.button("Rename").clicked(); cancel |= ui.button("Cancel").clicked(); }); }); if cancel || !open { self.show_rename = false; return; } if submit { if self.rename_selected() { self.show_rename = false; } else { // Rejected (already taken, or empty): keep the dialog up with // the typed text intact and take the focus back so it can be // fixed straight away. The status bar says what was wrong. self.rename_focus = true; } } } /// The confirmation behind the context menu's Delete, which is the one /// entry there that cannot be undone from inside the app. pub(super) fn confirm_delete_window(&mut self, ctx: &egui::Context) { let Some(name) = self.selected.and_then(|i| self.files.get(i)).cloned() else { self.confirm_delete = false; return; }; let mut open = self.confirm_delete; let mut delete = false; let mut cancel = false; egui::Window::new("Delete file?") .open(&mut open) .resizable(false) .collapsible(false) .show(ctx, |ui| { ui.label(name.as_str()); ui.label( egui::RichText::new( "This removes it from disk. Archive it instead to take it out \ of the manuscript but keep the file.", ) .small() .weak(), ); ui.separator(); ui.horizontal(|ui| { delete |= ui .button(egui::RichText::new("\u{1f5d1} Delete").color(egui::Color32::RED)) .clicked(); cancel |= ui.button("Cancel").clicked(); }); }); self.confirm_delete = open && !cancel && !delete; if delete { self.delete_selected(); } } pub(super) fn template_settings_window(&mut self, ctx: &egui::Context) { let mut open = self.show_template_settings; let mut close_clicked = false; egui::Window::new("New-file template") .open(&mut open) .resizable(true) .collapsible(false) .default_width(430.0) .show(ctx, |ui| { let mut save_now = false; ui.label( egui::RichText::new( "Seeded into files made with “+ New from template” in the file list.", ) .small() .weak(), ); ui.add_space(4.0); let r = ui.add( egui::TextEdit::multiline(&mut self.config.new_file_template) .code_editor() .desired_width(f32::INFINITY) .desired_rows(12), ); save_now |= r.lost_focus(); ui.add_space(4.0); ui.label(egui::RichText::new("Placeholders").strong()); egui::Grid::new("template_placeholders") .num_columns(2) .spacing([10.0, 2.0]) .show(ui, |ui| { for (token, meaning) in [ ("{{name}}", "the file stem, e.g. untitled-3"), ("{{marker}}", "the draft marker set in the toolbar"), ("{{date}}", "today's date (UTC), as YYYY-MM-DD"), ] { ui.label(egui::RichText::new(token).monospace()); ui.label(egui::RichText::new(meaning).weak()); ui.end_row(); } }); ui.add_space(4.0); ui.label( egui::RichText::new( "Leave the box empty to go back to the built-in default.", ) .small() .weak(), ); ui.separator(); ui.horizontal(|ui| { if ui.button("Close").clicked() { close_clicked = true; } if ui .button("Reset to default") .on_hover_text("Replace the box with the built-in template") .clicked() { self.config.new_file_template = crate::config::default_new_file_template(); save_now = true; } }); if save_now { self.config.save(); } }); let now_open = open && !close_clicked; if self.show_template_settings && !now_open { self.config.save(); } self.show_template_settings = now_open; } } /// Index of the first file the panel lists, which is the one to open a /// workspace on. /// /// In a project the panel shows the manuscript, so landing on `files[0]` would /// open whatever sorts first across the whole project — a scratch-pad note, /// typically — and in a file the user cannot see in the tree. Reference files /// are still the fallback, for a project whose manuscript folder is empty. /// Which folder to treat as the project root, from state the app already holds. /// /// Split out from [`App::project_root`] so the choice can be exercised without /// standing up a whole `App`. fn project_root_of<'a>( workspace: &'a Path, in_project_mode: bool, repo_root: Option<&'a Path>, pending_repo: Option<&'a Path>, ) -> &'a Path { if in_project_mode { return workspace; } repo_root.or(pending_repo).unwrap_or(workspace) } pub(super) fn first_listed(files: &[String], manuscript_dir: Option<&str>) -> Option { let in_book = |name: &String| match manuscript_dir { Some(dir) => is_within(name, dir), None => true, }; files .iter() .position(in_book) .or_else(|| (!files.is_empty()).then_some(0)) } /// The listed file nearest `idx` after a removal: the next one down, else the /// last one before it, else whatever is left. pub(super) fn nearest_listed( files: &[String], manuscript_dir: Option<&str>, idx: usize, ) -> Option { let in_book = |i: &usize| match manuscript_dir { Some(dir) => is_within(&files[*i], dir), None => true, }; (idx..files.len()) .find(in_book) .or_else(|| (0..idx.min(files.len())).rev().find(in_book)) .or_else(|| first_listed(files, manuscript_dir)) } /// Where the manuscript exports to for `workspace`, given the `current` export /// path. /// /// The folder is always the workspace (the project root). The file name is kept /// when it looks chosen — anything but the fallback `manuscript` or an echo of /// the folder the file sits in, both of which we generate ourselves. fn export_path_for(workspace: &Path, current: &Path) -> PathBuf { let stem = current.file_stem().and_then(|s| s.to_str()).unwrap_or(""); let old_folder = current .parent() .and_then(|p| p.file_name()) .and_then(|n| n.to_str()) .unwrap_or(""); let generated = stem.is_empty() || stem.eq_ignore_ascii_case("manuscript") || stem.eq_ignore_ascii_case(old_folder); let name = if generated { let folder = workspace .file_name() .and_then(|n| n.to_str()) .unwrap_or("manuscript"); format!("{folder}.odt") } else { current .file_name() .and_then(|n| n.to_str()) .unwrap_or("manuscript.odt") .to_string() }; workspace.join(name) } /// Delete `dir` (workspace-relative) and every parent it leaves childless, so /// the tree stops drawing branches nothing lives in any more. `remove_dir` /// refuses to touch a non-empty directory, which is exactly the guard wanted /// here; an empty `dir` is the workspace itself and is left alone. pub(super) fn prune_empty_dirs(workspace: &Path, dir: &str) { let mut dir = dir; while !dir.is_empty() { if std::fs::remove_dir(workspace.join(dir)).is_err() { break; } dir = parent_dir(dir); } } /// Move a manuscript file within the workspace, creating the destination folder /// and pruning the source folder when the move empties it. pub(super) fn move_manuscript_file( workspace: &Path, old: &str, new: &str, ) -> std::io::Result<()> { let new_path = workspace.join(new); if let Some(parent) = new_path.parent() { std::fs::create_dir_all(parent)?; } std::fs::rename(workspace.join(old), &new_path)?; prune_empty_dirs(workspace, parent_dir(old)); Ok(()) } /// First free `untitled-N.md`, so the template button needs no typed name. /// `is_taken` reports names already used on disk or in the manuscript order. pub(super) fn next_untitled_name(is_taken: impl Fn(&str) -> bool) -> String { for n in 1..=9_999 { let name = format!("untitled-{n}.md"); if !is_taken(&name) { return name; } } // Absurdly unlikely; fall back to something guaranteed unique-ish. format!("untitled-{}.md", chrono_like_timestamp().trim_start_matches('@')) } /// Expand the template placeholders and guarantee a trailing newline. pub(super) fn render_template(template: &str, stem: &str, marker: &str, date: &str) -> String { let mut out = template .replace("{{name}}", stem) .replace("{{marker}}", marker) .replace("{{date}}", date); if !out.ends_with('\n') { out.push('\n'); } out } #[cfg(test)] mod tests { use super::*; /// The folder the "open the project folder" button points at, across the /// shapes a workspace can take. #[test] fn the_project_root_is_the_folder_the_manuscript_sits_in() { let ws = Path::new("/books/My Book/06-First Draft"); let project = Path::new("/books/My Book"); // Project mode: the workspace already is the project root, so an // enclosing repository must not pull the answer above it. assert_eq!( project_root_of(project, true, Some(Path::new("/books")), None), project ); // The draft folder opened on its own, inside an adopted repository. assert_eq!(project_root_of(ws, false, Some(project), None), project); // The same, while the repository is still awaiting confirmation: // showing a folder is a smaller question than committing to it. assert_eq!(project_root_of(ws, false, None, Some(project)), project); // An adopted repository wins over a stale pending one. assert_eq!( project_root_of(ws, false, Some(project), Some(Path::new("/books"))), project ); // A plain, unversioned folder of chapters has no project around it, so // the workspace stands in rather than a guessed-at parent. assert_eq!(project_root_of(ws, false, None, None), ws); } #[test] fn untitled_name_fills_the_first_free_slot() { assert_eq!(next_untitled_name(|_| false), "untitled-1.md"); let taken = ["untitled-1.md", "untitled-2.md"]; assert_eq!( next_untitled_name(|n| taken.contains(&n)), "untitled-3.md" ); // A gap is reused rather than skipped. let sparse = ["untitled-1.md", "untitled-3.md"]; assert_eq!( next_untitled_name(|n| sparse.contains(&n)), "untitled-2.md" ); } #[test] fn render_template_expands_every_placeholder() { let out = render_template( "# Title: {{name}}\n# Started: {{date}}\n\n{{marker}}\n", "untitled-7", "### Rough Draft:", "2026-08-22", ); assert_eq!( out, "# Title: untitled-7\n# Started: 2026-08-22\n\n### Rough Draft:\n" ); } #[test] fn render_template_repeats_and_leaves_unknown_tokens_alone() { let out = render_template("{{name}}/{{name}} {{nope}}", "a", "M", "D"); assert_eq!(out, "a/a {{nope}}\n"); } #[test] fn render_template_always_ends_with_a_newline() { assert!(render_template("no trailing newline", "n", "m", "d").ends_with('\n')); // An already-terminated template doesn't collect a second one. assert_eq!(render_template("done\n", "n", "m", "d"), "done\n"); } #[test] fn render_template_tolerates_an_empty_marker() { // An empty draft_marker disables header splitting; the line goes blank. assert_eq!(render_template("a\n{{marker}}\nb\n", "n", "", "d"), "a\n\nb\n"); } /// The point of the default template is that the app's own header parser /// understands what it produces — otherwise the seeded fields are just text. #[test] fn default_template_round_trips_through_the_header_parser() { let marker = crate::config::default_marker(); let seeded = render_template( &crate::config::default_new_file_template(), "chapter-01", &marker, "2026-08-22", ); let h = crate::preprocess::parse(&seeded, &marker); assert_eq!(h.title.as_deref(), Some("chapter-01")); // Slug/POV are seeded blank, so they parse as absent rather than empty. assert_eq!(h.slug, None); assert_eq!(h.pov, None); assert_eq!(h.goal, None); // Nothing above the marker leaks into the exported prose. assert_eq!(h.body.trim(), ""); } /// A filled-in template parses into the fields the file list displays. #[test] fn filled_template_parses_into_header_fields() { let marker = crate::config::default_marker(); let seeded = render_template( "# Title: {{name}}\n# Slug: the door\n# POV: Ada\n\ # Word Count Target: 1500 - 2000\n\n{{marker}}\n\nReal prose here.\n", "chapter-02", &marker, "2026-08-22", ); let h = crate::preprocess::parse(&seeded, &marker); assert_eq!(h.title.as_deref(), Some("chapter-02")); assert_eq!(h.slug.as_deref(), Some("the door")); assert_eq!(h.pov.as_deref(), Some("Ada")); assert!(h.goal.is_some(), "word-count target should parse"); assert_eq!(h.body.trim(), "Real prose here."); } #[test] fn default_template_renders_to_a_usable_header() { let out = render_template( &crate::config::default_new_file_template(), "chapter-01", "### Rough Draft:", "2026-08-22", ); assert!(out.starts_with("# Title: chapter-01\n"), "got {out:?}"); assert!(out.contains("\n### Rough Draft:\n"), "got {out:?}"); assert!(!out.contains("{{"), "placeholder left unexpanded in {out:?}"); } #[test] fn opening_a_project_moves_a_generated_export_name_with_it() { let out = export_path_for( Path::new("/books/The Winter Gate"), Path::new("/books/Salt Road/Salt Road.odt"), ); assert_eq!( out, PathBuf::from("/books/The Winter Gate/The Winter Gate.odt") ); } #[test] fn a_chosen_export_name_survives_the_move_to_the_new_root() { let out = export_path_for( Path::new("/books/The Winter Gate"), Path::new("/books/Salt Road/submission draft.odt"), ); assert_eq!( out, PathBuf::from("/books/The Winter Gate/submission draft.odt") ); } #[test] fn the_default_export_name_is_re_derived_rather_than_kept() { let out = export_path_for( Path::new("/books/The Winter Gate"), Path::new("/home/writer/Manuscript/manuscript.odt"), ); assert_eq!( out, PathBuf::from("/books/The Winter Gate/The Winter Gate.odt") ); } #[test] fn reopening_the_same_workspace_leaves_the_export_untouched() { let ws = Path::new("/books/The Winter Gate"); let current = PathBuf::from("/books/The Winter Gate/submission draft.odt"); assert_eq!(export_path_for(ws, ¤t), current); } /// Build a throwaway workspace containing `files` and hand back its path. fn scratch_ws(tag: &str, files: &[&str]) -> PathBuf { let dir = std::env::temp_dir().join(format!("md_manuscript_ws_{tag}")); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); for rel in files { let path = dir.join(rel); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(&path, "x").unwrap(); } dir } #[test] fn moving_a_file_creates_the_destination_and_clears_the_source_folder() { let ws = scratch_ws("move", &["part-1/ch-01.md"]); move_manuscript_file(&ws, "part-1/ch-01.md", "part-2/ch-01.md").unwrap(); assert!(ws.join("part-2/ch-01.md").is_file()); assert!(!ws.join("part-1").exists(), "the emptied folder should go"); let _ = std::fs::remove_dir_all(&ws); } #[test] fn moving_a_file_leaves_a_folder_that_still_holds_others() { let ws = scratch_ws("move_keep", &["p/a.md", "p/b.md"]); move_manuscript_file(&ws, "p/a.md", "a.md").unwrap(); assert!(ws.join("a.md").is_file()); assert!(ws.join("p/b.md").is_file()); assert!(ws.join("p").is_dir(), "a folder with files left must survive"); let _ = std::fs::remove_dir_all(&ws); } #[test] fn moving_into_a_folder_that_does_not_exist_yet_creates_it() { let ws = scratch_ws("move_new", &["a.md"]); move_manuscript_file(&ws, "a.md", "part-3/deep/a.md").unwrap(); assert!(ws.join("part-3/deep/a.md").is_file()); let _ = std::fs::remove_dir_all(&ws); } #[test] fn moving_a_missing_file_fails_without_disturbing_the_workspace() { let ws = scratch_ws("move_missing", &["a.md"]); assert!(move_manuscript_file(&ws, "nope.md", "p/nope.md").is_err()); assert!(ws.join("a.md").is_file()); let _ = std::fs::remove_dir_all(&ws); } #[test] fn pruning_walks_up_through_every_folder_it_empties() { let ws = scratch_ws("prune", &["a/b/c/only.md", "keep.md"]); std::fs::remove_file(ws.join("a/b/c/only.md")).unwrap(); prune_empty_dirs(&ws, "a/b/c"); assert!(!ws.join("a").exists(), "the whole empty chain should go"); assert!(ws.join("keep.md").is_file()); let _ = std::fs::remove_dir_all(&ws); } #[test] fn pruning_stops_at_the_first_folder_still_holding_something() { let ws = scratch_ws("prune_stop", &["a/keep.md", "a/b/gone.md"]); std::fs::remove_file(ws.join("a/b/gone.md")).unwrap(); prune_empty_dirs(&ws, "a/b"); assert!(!ws.join("a/b").exists()); assert!(ws.join("a").is_dir(), "`a` still holds keep.md"); let _ = std::fs::remove_dir_all(&ws); } #[test] fn pruning_never_removes_the_workspace_itself() { let ws = scratch_ws("prune_root", &[]); prune_empty_dirs(&ws, ""); assert!(ws.is_dir()); let _ = std::fs::remove_dir_all(&ws); } fn v(items: &[&str]) -> Vec { items.iter().map(|s| s.to_string()).collect() } /// The real shape of the bug: the scratch pad sorts first, so opening a /// project landed on a note that the panel does not even list. #[test] fn opening_a_project_lands_in_the_manuscript() { let files = v(&[ "00-Scratch Pad/Generated plot points.md", "03-Characters/ada.md", "06-First Draft/Act 1/scene01.md", "06-First Draft/draft_v1.md", ]); assert_eq!(first_listed(&files, Some("06-First Draft")), Some(2)); } #[test] fn a_plain_folder_still_opens_on_its_first_file() { let files = v(&["a.md", "b.md"]); assert_eq!(first_listed(&files, None), Some(0)); assert_eq!(first_listed(&[], None), None); } /// A project whose manuscript folder holds nothing yet still has to open /// something rather than nothing. #[test] fn an_empty_manuscript_falls_back_to_the_first_file() { let files = v(&["00-Scratch Pad/note.md", "03-Characters/ada.md"]); assert_eq!(first_listed(&files, Some("06-First Draft")), Some(0)); } #[test] fn after_a_removal_the_next_listed_file_is_chosen() { let files = v(&[ "00-Scratch Pad/note.md", "06-First Draft/a.md", "06-First Draft/b.md", ]); // Removing index 1 leaves the following manuscript file at index 1. assert_eq!(nearest_listed(&files, Some("06-First Draft"), 1), Some(1)); // Removing the last one falls back to the previous manuscript file. assert_eq!(nearest_listed(&files, Some("06-First Draft"), 3), Some(2)); } #[test] fn a_removal_never_lands_on_a_reference_file() { let files = v(&["00-Scratch Pad/note.md", "06-First Draft/a.md"]); // Index 2 is past the end; the search back must skip the scratch pad. assert_eq!(nearest_listed(&files, Some("06-First Draft"), 2), Some(1)); } #[test] fn removing_the_only_manuscript_file_still_selects_something() { let files = v(&["00-Scratch Pad/note.md"]); assert_eq!(nearest_listed(&files, Some("06-First Draft"), 0), Some(0)); assert_eq!(nearest_listed(&[], Some("06-First Draft"), 0), None); } }