//! 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. 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()); 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); } None => { self.is_repo = false; self.repo_root = None; } } } /// 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); } } /// 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()) } pub(super) fn rename_selected(&mut self) { let Some(idx) = self.selected else { return }; // 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; }; let Some(old_name) = self.files.get(idx).cloned() else { return; }; if new_name == old_name { return; } if self.path_for(&new_name).exists() { self.status = format!("{new_name} already exists"); return; } // 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}"); } Err(e) => self.status = format!("Rename failed: {e}"), } } 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.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() } 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 mut dialog = rfd::FileDialog::new() .set_title("Choose export file") .add_filter("OpenDocument Text", &["odt"]); if let Some(parent) = current.parent().filter(|p| p.is_dir()) { dialog = dialog.set_directory(parent); } let name = current .file_name() .and_then(|s| s.to_str()) .unwrap_or("manuscript.odt"); if let Some(mut path) = dialog.set_file_name(name).save_file() { // Ensure the chosen path ends in .odt even if the user omitted it. let has_odt = path .extension() .and_then(|e| e.to_str()) .is_some_and(|e| e.eq_ignore_ascii_case("odt")); if !has_odt { path.set_extension("odt"); } 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. 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); } }