ab9aa2afce
Bare proper nouns in prose ("Bixby walked in") now resolve against the
Story Bible automatically, the same as the existing [[wiki-links]] and
@mentions markup — no manual linking required. Matching is case-sensitive
and requires a capitalized, 3+ character match to a sheet's name or alias,
which is what keeps a character named "Hope" or "Will" from lighting up
on every unrelated use of the word; the longest phrase wins an overlap
("Bixby Reynolds" over "Bixby"), and a bare match never overrides the
author's own explicit markup.
Hovering a highlighted mention shows a card with the item's name, type
(Person/Place/Thing) and a summary (from a new summary:/description:
frontmatter field), with a link that opens the entry in the Story Bible
editor. The card appears after a short delay and stays open while the
pointer crosses onto it to reach the link.
Along the way:
- StoryBibleReference now carries absolute byte offsets (start/end) and
the resolved item_id, replacing column/length, which were actually
per-line and were being used as whole-buffer offsets — every reference
past a file's first line highlighted and jumped-to in the wrong place.
find_references() also now walks the buffer one char at a time rather
than one byte, which could previously land mid-character and panic on
any manuscript with accented letters or an em dash.
- build_story_bible_index() had its own, separately hardcoded list of
Story Bible directory names that had drifted from
find_story_bible_directory()'s list and no longer included
03-Story-Bible, the bundled template's actual folder — so the index,
and everything built on it, was silently empty for every project made
from that template. It now reuses the one list.
- Story Bible Editor window: its tree and content ScrollAreas (and the
content TextEdit) had no id_salt, so egui treated them as the same
widget — fixed with explicit salts.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2299 lines
93 KiB
Rust
2299 lines
93 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;
|
||
}
|
||
// Stop any existing workspace watcher before starting a new one.
|
||
self.stop_watcher();
|
||
// Start a file watcher for automatic refresh on file system changes.
|
||
self.start_watcher(&ws);
|
||
// 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.build_story_bible_index();
|
||
self.autocomplete = None;
|
||
self.collapsed.clear();
|
||
// Ensure the manuscript folder (e.g., 05-First Draft) is always expanded
|
||
if let Some(ref dir) = self.manuscript_dir {
|
||
self.collapsed.remove(dir);
|
||
}
|
||
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()),
|
||
};
|
||
}
|
||
|
||
/// Start a file system watcher for the workspace directory that sends a
|
||
/// refresh notification whenever files are created, deleted, or renamed.
|
||
/// Uses a polling approach for compatibility across platforms and Cargo versions.
|
||
pub(super) fn start_watcher(&mut self, ws: &Path) {
|
||
let (tx, rx) = mpsc::channel();
|
||
let ws_clone = ws.to_path_buf();
|
||
|
||
// Stop any existing watcher first
|
||
self.stop_watcher();
|
||
|
||
self.watcher_rx = Some(rx);
|
||
self.watcher_thread = Some(thread::spawn(move || {
|
||
use std::collections::HashSet;
|
||
use std::fs;
|
||
|
||
/// Recursively collect all markdown file paths relative to workspace root.
|
||
fn collect_paths(path: &Path, workspace: &Path, files: &mut HashSet<String>) {
|
||
if path.is_dir() {
|
||
if let Ok(entries) = fs::read_dir(path) {
|
||
for entry in entries.flatten() {
|
||
collect_paths(&entry.path(), workspace, files);
|
||
}
|
||
}
|
||
} else if path.extension().is_some_and(|ext| ext.eq_ignore_ascii_case("md")) {
|
||
if let Ok(rel_path) = path.strip_prefix(workspace) {
|
||
if let Some(rel_str) = rel_path.to_str() {
|
||
files.insert(rel_str.replace('\\', "/"));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Build initial snapshot of all markdown files
|
||
let mut previous_files: HashSet<String> = HashSet::new();
|
||
if let Ok(entries) = fs::read_dir(&ws_clone) {
|
||
for entry in entries.flatten() {
|
||
collect_paths(&entry.path(), &ws_clone, &mut previous_files);
|
||
}
|
||
}
|
||
|
||
// Poll for changes every 2 seconds
|
||
loop {
|
||
thread::sleep(Duration::from_secs(2));
|
||
|
||
let mut current_files: HashSet<String> = HashSet::new();
|
||
if let Ok(entries) = fs::read_dir(&ws_clone) {
|
||
for entry in entries.flatten() {
|
||
collect_paths(&entry.path(), &ws_clone, &mut current_files);
|
||
}
|
||
}
|
||
|
||
// Check if files changed
|
||
if current_files != previous_files {
|
||
let _ = tx.send(WatcherMsg);
|
||
previous_files = current_files;
|
||
}
|
||
}
|
||
}));
|
||
}
|
||
|
||
/// Stop the file system watcher and clean up its resources.
|
||
pub(super) fn stop_watcher(&mut self) {
|
||
// Dropping the receiver will cause the sender to fail on next send,
|
||
// but the watcher thread doesn't check for that. We need to stop it properly.
|
||
// For now, just drop everything - the watcher will be garbage collected.
|
||
self.watcher_rx = None;
|
||
if let Some(handle) = self.watcher_thread.take() {
|
||
// We can't actually stop the notify watcher cleanly without a signal,
|
||
// but dropping the receiver means the callback's send will fail.
|
||
// The thread will continue running but the sends will silently fail.
|
||
// This is good enough for our purposes.
|
||
drop(handle);
|
||
}
|
||
}
|
||
|
||
/// Poll for file change notifications and refresh the workspace if one arrived.
|
||
/// Uses try_recv so it doesn't block the UI thread.
|
||
pub(super) fn poll_watcher(&mut self) {
|
||
if let Some(rx) = self.watcher_rx.take() {
|
||
while let Ok(WatcherMsg) = rx.try_recv() {
|
||
// Refresh the file list
|
||
self.open_workspace();
|
||
}
|
||
// Put it back for next frame
|
||
self.watcher_rx = Some(rx);
|
||
}
|
||
}
|
||
|
||
/// 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 (`05-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()
|
||
}
|
||
|
||
/// Build the Story Bible index from the Story Bible directory.
|
||
///
|
||
/// Uses [`Self::find_story_bible_directory`] for the primary lookup —
|
||
/// this used to keep its own, separately-hardcoded list of directory
|
||
/// names, which had drifted out of sync and no longer included
|
||
/// `03-Story-Bible`, the bundled template's actual folder. That silently
|
||
/// left the index empty (no highlighting, no hover cards, no hits in the
|
||
/// Story Bible window) for every project made from the snowflake
|
||
/// template, so there is now exactly one place that name is spelled.
|
||
pub(super) fn build_story_bible_index(&mut self) {
|
||
use std::path::Path;
|
||
if let Some(dir_path) = self.find_story_bible_directory() {
|
||
self.story_bible_index = crate::story_bible::StoryBibleIndex::build(&dir_path);
|
||
return;
|
||
}
|
||
self.story_bible_index = crate::story_bible::StoryBibleIndex::default();
|
||
// No single Story Bible directory found — fall back to merging
|
||
// whatever scattered character/location/lore folders exist.
|
||
let ws = self.config.workspace.clone();
|
||
let possible_dirs = ["03-Characters", "04-Locations", "05-Lore", "Characters", "Locations", "Lore"];
|
||
for dir_name in possible_dirs.iter() {
|
||
let dir_path = Path::new(&ws).join(dir_name);
|
||
if dir_path.exists() && dir_path.is_dir() {
|
||
let index = crate::story_bible::StoryBibleIndex::build(&dir_path);
|
||
for (id, item) in index.items_by_id {
|
||
self.story_bible_index.items_by_id.insert(id, item.clone());
|
||
}
|
||
for (name, item) in index.items_by_name {
|
||
self.story_bible_index.items_by_name.insert(name, item);
|
||
}
|
||
for (alias, item) in index.items_by_alias {
|
||
self.story_bible_index.items_by_alias.insert(alias, item);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 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),
|
||
}
|
||
}
|
||
|
||
/// Whether the open file has a header to fold away.
|
||
pub(super) fn header_is_foldable(&self) -> bool {
|
||
header_is_foldable(&self.buffer, &self.config.draft_marker)
|
||
}
|
||
|
||
/// Lift the header out of the buffer, leaving the prose. Supports both
|
||
/// YAML frontmatter (`---` delimiters) and the legacy draft marker.
|
||
pub(super) fn collapse_header(&mut self) {
|
||
if self.header_stash.is_some() {
|
||
return;
|
||
}
|
||
|
||
// Try YAML frontmatter first
|
||
if let Some((header, body)) = crate::preprocess::split_yaml_frontmatter(&self.buffer) {
|
||
let (header, body) = (header.to_string(), body.to_string());
|
||
self.header_stash = Some(header);
|
||
self.buffer = body;
|
||
self.after_view_change();
|
||
return;
|
||
}
|
||
|
||
// Fall back to draft marker
|
||
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;
|
||
// Update Story Bible references for the newly selected file
|
||
self.update_current_references();
|
||
// 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();
|
||
}
|
||
|
||
/// Create a new scene file: its planning metadata as frontmatter, and the
|
||
/// prose below it. Used for the "New Scene" button.
|
||
pub(super) fn create_scene_pair(&mut self) {
|
||
// Determine which folder we're in
|
||
let current_dir = self
|
||
.selected
|
||
.and_then(|i| self.files.get(i))
|
||
.map(|path| parent_dir(path).to_string())
|
||
.unwrap_or_default();
|
||
|
||
// Check if we're in a First Draft folder (Act 1, Act 2, etc.)
|
||
let in_draft_folder = current_dir.starts_with("05-First Draft/Act")
|
||
|| current_dir.starts_with("05-First Draft/");
|
||
|
||
// Extract act number from current directory
|
||
let act_num = if in_draft_folder {
|
||
// Parse "05-First Draft/Act N" or "05-First Draft/Act N/"
|
||
current_dir.split('/').nth(1).and_then(|s| {
|
||
s.strip_prefix("Act ").and_then(|n| n.parse::<u32>().ok())
|
||
})
|
||
} else {
|
||
// Default to Act 1
|
||
Some(1)
|
||
};
|
||
|
||
let act = act_num.unwrap_or(1);
|
||
// Find next available scene number in this act
|
||
let existing_scenes: Vec<u32> = self.files.iter()
|
||
.filter(|f| {
|
||
let path_lower = f.to_lowercase();
|
||
if in_draft_folder {
|
||
// Check files in current act
|
||
path_lower.contains(&format!("act {}/", act))
|
||
|| path_lower.contains(&format!("Act {}/", act))
|
||
} else {
|
||
// Check files in 05-First Draft
|
||
path_lower.contains(&format!("05-first draft/act {}/scene", act))
|
||
|| path_lower.contains(&format!("05-first draft/Act {}/scene", act))
|
||
}
|
||
})
|
||
.filter_map(|f| {
|
||
// Extract scene number from filename
|
||
let filename = Path::new(f).file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||
let num_str = filename.trim_start_matches("scene").trim_start_matches("_").trim_end_matches(".md");
|
||
num_str.parse::<u32>().ok()
|
||
})
|
||
.collect();
|
||
|
||
let scene_num = (1..=1000).find(|n| !existing_scenes.contains(n)).unwrap_or(1);
|
||
|
||
// Default chapter is 1
|
||
let chapter = 1;
|
||
|
||
// Generate scene title
|
||
let title = format!("Chapter {}: Scene {}", chapter, scene_num);
|
||
let slug = format!("chapter-{}--scene-{}", chapter, scene_num);
|
||
|
||
// Determine where to create file
|
||
let draft_dir = format!("05-First Draft/Act {}", act);
|
||
|
||
// Scene file
|
||
let filename = format!("{}/scene{:02}.md", draft_dir, scene_num);
|
||
|
||
let content = scene_file_contents(&title, &slug, act, chapter, scene_num);
|
||
|
||
// Create the merged file
|
||
self.insert_new_file(filename.clone(), content);
|
||
|
||
// Select the new file
|
||
let file_idx = self.files.iter().position(|f| *f == filename).unwrap_or(0);
|
||
self.selected = Some(file_idx);
|
||
self.buffer.clear();
|
||
self.dirty = false;
|
||
}
|
||
|
||
/// 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. The host is taken from the server URL \
|
||
above rather than from what Gitea advertises, so the remote \
|
||
points at an address this machine can actually reach. 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('@'))
|
||
}
|
||
|
||
/// Whether a document has a header to fold away — YAML frontmatter, or a legacy
|
||
/// block above the draft marker.
|
||
///
|
||
/// Split out from [`App::header_is_foldable`] so the choice can be exercised
|
||
/// without an `App`. The two forms are asked about together everywhere the fold
|
||
/// is offered, so they are asked about here once: the toggle above the editor
|
||
/// used to ask only about the draft marker, which meant a frontmatter file —
|
||
/// the one form the app now writes — never offered the fold at all.
|
||
fn header_is_foldable(buffer: &str, marker: &str) -> bool {
|
||
crate::preprocess::split_yaml_frontmatter(buffer).is_some()
|
||
|| crate::preprocess::split_at_marker(buffer, marker).is_some()
|
||
}
|
||
|
||
/// A new scene file: everything it knows about itself in the frontmatter, and
|
||
/// nothing below the closing `---` but the prose placeholder.
|
||
///
|
||
/// The body used to restate the whole header — a metadata table, a word-count
|
||
/// table, POV/characters/setting/conflict/theme/tone sections, a notes block and
|
||
/// a checklist. All of that is *body*, which is what the word count counts and
|
||
/// what the exporter writes into the .odt, so every scene opened a hundred and
|
||
/// fifty words and half a page into its own export before a line was written.
|
||
fn scene_file_contents(title: &str, slug: &str, act: u32, chapter: u32, scene: u32) -> String {
|
||
format!(
|
||
"---\n\
|
||
title: \"{title}\"\n\
|
||
slug: {slug}\n\
|
||
act: {act}\n\
|
||
chapter: {chapter}\n\
|
||
scene: {scene}\n\
|
||
status: draft\n\
|
||
pov: \"\"\n\
|
||
characters: []\n\
|
||
setting: \"\"\n\
|
||
date: \"\"\n\
|
||
time: \"\"\n\
|
||
conflict: \"\"\n\
|
||
theme: \"\"\n\
|
||
tone: \"\"\n\
|
||
word_count_target: 1800-2200\n\
|
||
---\n\
|
||
\n\
|
||
[Write your scene content here...]\n"
|
||
)
|
||
}
|
||
|
||
/// 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::*;
|
||
|
||
#[test]
|
||
fn a_frontmatter_scene_offers_the_fold() {
|
||
// An explicit marker: the default is empty now that scenes are written
|
||
// as frontmatter, so it would not exercise the legacy arm.
|
||
let marker = "### Rough Draft:";
|
||
let scene = scene_file_contents("Chapter 1: Scene 2", "chapter-1--scene-2", 1, 1, 2);
|
||
assert!(header_is_foldable(&scene, marker), "frontmatter should fold");
|
||
// A frontmatter file has no draft marker, which is exactly why asking
|
||
// only about the marker hid the toggle on every scene the app writes.
|
||
assert!(crate::preprocess::split_at_marker(&scene, marker).is_none());
|
||
|
||
// The legacy form still folds, and prose alone still does not.
|
||
let legacy = "# Title: The Gate\n\n### Rough Draft:\n\nProse.\n";
|
||
assert!(header_is_foldable(legacy, marker), "a draft marker should fold");
|
||
assert!(!header_is_foldable("Just prose.\n", marker), "nothing to fold");
|
||
// Clearing the marker disables legacy splitting but not frontmatter,
|
||
// which is delimited by `---` rather than by the marker.
|
||
assert!(!header_is_foldable(legacy, ""));
|
||
assert!(header_is_foldable(&scene, ""));
|
||
}
|
||
|
||
#[test]
|
||
fn folding_a_scene_leaves_the_prose_and_keeps_the_file_whole() {
|
||
// The fold is a view: `document()` glues the stash back on for every
|
||
// save, so what is on disk must not change for having been folded.
|
||
let scene = scene_file_contents("Chapter 1: Scene 2", "chapter-1--scene-2", 1, 1, 2);
|
||
let (header, body) =
|
||
crate::preprocess::split_yaml_frontmatter(&scene).expect("frontmatter");
|
||
assert!(
|
||
!body.contains("word_count_target"),
|
||
"no frontmatter left in the editor: {body:?}"
|
||
);
|
||
assert_eq!(body.trim(), "[Write your scene content here...]");
|
||
assert_eq!(format!("{header}{body}"), scene, "the file is unchanged");
|
||
// What the toggle's label counts while folded.
|
||
assert_eq!(crate::preprocess::fields(header, "").len(), 15);
|
||
}
|
||
|
||
/// The template the snowflake project ships and the file "+ New Scene"
|
||
/// writes have to stay the same shape, so check the bundled one too.
|
||
const BUNDLED_TEMPLATE: &str =
|
||
include_str!("../../templates/snowflake/{{ cookiecutter.project_name }}/05-First Draft/_template.md");
|
||
|
||
#[test]
|
||
fn a_new_scene_puts_nothing_but_prose_below_the_frontmatter() {
|
||
// The body is what the word count counts and what the exporter writes,
|
||
// so the header must not be restated in it. It used to be, at length.
|
||
for source in [
|
||
scene_file_contents("Chapter 1: Scene 3", "chapter-1--scene-3", 1, 1, 3),
|
||
BUNDLED_TEMPLATE.to_string(),
|
||
] {
|
||
let header = crate::preprocess::parse(&source, "### Rough Draft:");
|
||
assert_eq!(header.body, "[Write your scene content here...]");
|
||
assert!(
|
||
count_words(&header.body) < 10,
|
||
"a new scene should not open with a page of metadata"
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_new_scene_says_each_thing_once() {
|
||
let source = scene_file_contents("Chapter 1: Scene 3", "chapter-1--scene-3", 2, 1, 3);
|
||
let fields = crate::preprocess::fields(&source, "### Rough Draft:");
|
||
let mut names: Vec<String> = fields.iter().map(|f| f.name.to_lowercase()).collect();
|
||
names.sort();
|
||
let before = names.len();
|
||
names.dedup();
|
||
assert_eq!(before, names.len(), "a key is written twice: {names:?}");
|
||
// Nothing the app cannot keep true: these were stale the moment you
|
||
// typed, and `order`/`id` restated order.json and the file's own path.
|
||
for gone in [
|
||
"id", "type", "version", "order", "created", "updated", "word_count",
|
||
"word_count_updated", "session_word_count", "last_session", "total_sessions",
|
||
] {
|
||
assert!(!names.contains(&gone.to_string()), "{gone} is back");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_new_scene_is_read_back_through_the_frontmatter() {
|
||
// The point of favouring YAML: every reader gets its answer from there.
|
||
let source = scene_file_contents("Chapter 1: Scene 3", "chapter-1--scene-3", 2, 1, 3);
|
||
let header = crate::preprocess::parse(&source, "### Rough Draft:");
|
||
assert_eq!(header.title.as_deref(), Some("Chapter 1: Scene 3"));
|
||
assert_eq!(header.slug.as_deref(), Some("chapter-1--scene-3"));
|
||
let goal = header.goal.expect("the target is readable");
|
||
assert_eq!((goal.min, goal.max), (1800, 2200));
|
||
assert_eq!(
|
||
crate::preprocess::field(&source, "", "status").as_deref(),
|
||
Some("draft")
|
||
);
|
||
assert_eq!(crate::preprocess::field(&source, "", "act").as_deref(), Some("2"));
|
||
// Unfilled reads as absent, so an empty `pov:` is not a POV of "".
|
||
assert_eq!(crate::preprocess::field(&source, "", "pov"), None);
|
||
assert_eq!(crate::preprocess::field(&source, "", "characters"), None);
|
||
}
|
||
|
||
/// 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/05-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(
|
||
"---\ntitle: {{name}}\n---\n",
|
||
"untitled-7",
|
||
"",
|
||
"2026-08-22",
|
||
);
|
||
assert_eq!(
|
||
out,
|
||
"---\ntitle: untitled-7\n---\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 --- 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(
|
||
"---\ntitle: {{name}}\nslug: the door\npov: Ada\nword_count_target: 1500 - 2000\n---\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",
|
||
"",
|
||
"2026-08-22",
|
||
);
|
||
assert!(out.starts_with("---\n"), "got {out:?}");
|
||
assert!(out.contains("title: chapter-01"), "got {out:?}");
|
||
assert!(out.contains("---\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<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",
|
||
"05-First Draft/Act 1/scene01.md",
|
||
"05-First Draft/draft_v1.md",
|
||
]);
|
||
assert_eq!(first_listed(&files, Some("05-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("05-First Draft")), Some(0));
|
||
}
|
||
|
||
#[test]
|
||
fn after_a_removal_the_next_listed_file_is_chosen() {
|
||
let files = v(&[
|
||
"00-Scratch Pad/note.md",
|
||
"05-First Draft/a.md",
|
||
"05-First Draft/b.md",
|
||
]);
|
||
// Removing index 1 leaves the following manuscript file at index 1.
|
||
assert_eq!(nearest_listed(&files, Some("05-First Draft"), 1), Some(1));
|
||
// Removing the last one falls back to the previous manuscript file.
|
||
assert_eq!(nearest_listed(&files, Some("05-First Draft"), 3), Some(2));
|
||
}
|
||
|
||
#[test]
|
||
fn a_removal_never_lands_on_a_reference_file() {
|
||
let files = v(&["00-Scratch Pad/note.md", "05-First Draft/a.md"]);
|
||
// Index 2 is past the end; the search back must skip the scratch pad.
|
||
assert_eq!(nearest_listed(&files, Some("05-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("05-First Draft"), 0), Some(0));
|
||
assert_eq!(nearest_listed(&[], Some("05-First Draft"), 0), None);
|
||
}
|
||
}
|
||
|