Files
md-manuscript/src/app/workspace.rs
T
landon c4391155cd Create the Gitea repository from the app, not a template hook
Repository creation was delegated entirely to a cookiecutter template's
post-gen hook. That only ever covered projects made through New project,
and when it went wrong it went wrong inside someone else's Python, where
all this app could report was that a hook exited non-zero.

The app now talks to Gitea itself. Settings > Git remote... gains a
"Create and attach" button: it POSTs to /api/v1/user/repos with the
configured server, user and token, then points origin at the clone URL
that comes back. A name that is already taken is fetched and attached to
rather than raised as an error, so retrying after a failure works.

The call runs on a worker thread with the existing channel-and-poll
pattern, so the UI does not block on the network. Errors are mapped by
status code, because a rejected token, a URL that is not a Gitea API and
a name the server refuses each need a different fix.

Uses ureq's send_string rather than send_json, whose feature this build
does not enable; no new dependency, and ldd still shows only
libc/libgcc/libm.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBWj9TphFMCoh7VHaSRnvQ
2026-09-09 18:13:21 -05:00

1925 lines
76 KiB
Rust

//! 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<String> {
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<String, usize> {
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<String, FileMeta> {
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<String> {
let mut seen: Vec<String> = 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<String> =
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<Chapter> {
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<String> {
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<usize> {
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<usize> {
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, &current), 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<String> {
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);
}
}