f35397b49b
Five changes to the export and the window chrome: * Manuscript details gains a contact field and a "Begin exports with a title page" option: the title, the author beneath it, and the contact details beneath that, laid out line for line as typed. A chapters + master export puts the title page on the master, not on each chapter. * "Standard manuscript format" lays an export out the way an agent or an editor expects a submission: 12pt Courier, double-spaced, half-inch first-line indents, a Surname / Title / page header on every page but the title page, chapters opening a third of the way down, and `---` rendered as the conventional centred `#` scene break. The title page becomes the submission kind, with contact top-left and an approximate word count top-right. Off by default; margins were already the standard 1in on US Letter and are unchanged either way. * A folder button beside Export ODT opens the project folder in the file manager -- the enclosing git work tree, including one still awaiting confirmation, since showing a folder is a smaller question than choosing which repository to commit to. * The toolbar and the file list each collapse, from the pair of buttons at the right of the menu bar or from the View menu, and Ctrl+D folds both away together for distraction-free writing. Both choices persist. * The editor sizes to its viewport instead of a fixed 30 rows, so the height a folded panel gives back reaches the page rather than leaving grey space below the text box that did not even take focus. Two traps worth recording. ODF's style:master-page-name is inherited, and any paragraph style carrying one forces a page break before every paragraph that uses it -- a four-line contact block came out as four pages until Contact_20_Line stopped inheriting from Contact_20_Block; a test now pins which styles may carry one. And the status bar shares a function with the toolbar, so guarding that function's top rather than the top panel alone silently took the word counts away with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GYSGPDwkSzhm4qLqjCbqxU
1459 lines
57 KiB
Rust
1459 lines
57 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.
|
|
|
|
use super::*;
|
|
|
|
impl App {
|
|
pub(super) fn workspace(&self) -> &Path {
|
|
&self.config.workspace
|
|
}
|
|
|
|
/// The project folder: the root holding the characters, the outline and the
|
|
/// reference material alongside the draft folder.
|
|
///
|
|
/// In project mode the workspace already *is* that root. Otherwise the
|
|
/// workspace is a folder of chapters one level down, and the enclosing git
|
|
/// work tree is the project around it — including one still awaiting the
|
|
/// user's confirmation, since which folder to show in a file manager is a
|
|
/// smaller question than which repository to commit to.
|
|
///
|
|
/// Falls back to the workspace when there is no repository, rather than
|
|
/// guessing at a parent: an unversioned folder of chapters has no project
|
|
/// around it that we can point to with any confidence. Reads only cached
|
|
/// state, because the toolbar asks for this every frame.
|
|
pub(super) fn project_root(&self) -> &Path {
|
|
project_root_of(
|
|
&self.config.workspace,
|
|
self.manuscript_dir.is_some(),
|
|
self.repo_root.as_deref(),
|
|
self.pending_repo.as_deref(),
|
|
)
|
|
}
|
|
|
|
/// Show the project folder in the desktop's file manager.
|
|
pub(super) fn open_project_folder(&mut self) {
|
|
let path = self.project_root().to_path_buf();
|
|
if !path.is_dir() {
|
|
self.status = format!("No such folder: {}", path.display());
|
|
return;
|
|
}
|
|
// xdg-open picks whichever file manager the session provides, the same
|
|
// desktop-portal assumption the native file dialogs already make.
|
|
let spawned = std::process::Command::new("xdg-open")
|
|
.arg(&path)
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::null())
|
|
.spawn();
|
|
match spawned {
|
|
Ok(mut child) => {
|
|
// xdg-open hands off to the file manager and exits immediately.
|
|
// Reap it off-thread so each click does not leave a zombie behind.
|
|
std::thread::spawn(move || {
|
|
let _ = child.wait();
|
|
});
|
|
self.status = format!("Opened {}", path.display());
|
|
}
|
|
Err(e) => self.status = format!("Could not open {}: {e}", path.display()),
|
|
}
|
|
}
|
|
|
|
/// (Re)load the file list for the current workspace, creating the directory
|
|
/// if needed, and refresh git status.
|
|
pub(super) fn open_workspace(&mut self) {
|
|
let ws = self.config.workspace.clone();
|
|
if let Err(e) = std::fs::create_dir_all(&ws) {
|
|
self.status = format!("Cannot create workspace: {e}");
|
|
return;
|
|
}
|
|
// The project root has moved, so the export follows it.
|
|
self.retarget_export();
|
|
// Which folder is the manuscript has to be settled before the file
|
|
// list is read, since it decides what counts as a chapter.
|
|
self.manuscript_dir = self.detect_manuscript_dir(&ws);
|
|
self.files = order::resolve_order(&ws, &self.config.hidden_folders);
|
|
self.titles = order::read_titles(&ws);
|
|
self.wordlist = crate::spell::read_wordlist(&ws);
|
|
// Drop overrides for files that no longer exist.
|
|
self.titles.retain(|name, _| self.files.contains(name));
|
|
self.detect_repo(&ws);
|
|
self.selected = None;
|
|
self.buffer.clear();
|
|
self.header_stash = None;
|
|
self.dirty = false;
|
|
self.pending_delete = false;
|
|
self.clear_lt();
|
|
self.dismissed.clear();
|
|
self.session_start_counts = self.snapshot_counts();
|
|
self.file_meta = self.snapshot_file_meta();
|
|
self.rebuild_field_names();
|
|
self.load_characters();
|
|
self.autocomplete = None;
|
|
self.collapsed.clear();
|
|
if let Some(idx) = first_listed(&self.files, self.manuscript_dir.as_deref()) {
|
|
self.select(idx);
|
|
}
|
|
self.persist_order();
|
|
self.status = match &self.manuscript_dir {
|
|
Some(dir) => {
|
|
let chapters = self.manuscript_files().len();
|
|
format!(
|
|
"{chapters} chapter(s) in {dir}, {} reference file(s) — {}",
|
|
self.files.len() - chapters,
|
|
ws.display()
|
|
)
|
|
}
|
|
None => format!("{} file(s) in {}", self.files.len(), ws.display()),
|
|
};
|
|
}
|
|
|
|
/// Point the export at the opened workspace, which is the project root.
|
|
///
|
|
/// A file name the user picked deliberately is carried across; one that
|
|
/// merely echoed the previous project's folder is re-derived, so exports
|
|
/// land beside the book being worked on instead of piling up in the
|
|
/// project left behind.
|
|
pub(super) fn retarget_export(&mut self) {
|
|
let path = export_path_for(&self.config.workspace, &self.config.export_path);
|
|
if path != self.config.export_path {
|
|
self.config.export_path = path;
|
|
self.config.save();
|
|
}
|
|
self.export_input = self.config.export_path.display().to_string();
|
|
}
|
|
|
|
/// Work out the git situation for a freshly opened workspace.
|
|
///
|
|
/// If the folder is itself a repository root it is adopted silently. If it
|
|
/// is not, but an enclosing parent directory is a git work tree, we stash
|
|
/// that parent in `pending_repo` and ask the user to confirm before using
|
|
/// it (see [`repo_prompt`](Self::repo_prompt)). Otherwise the workspace is
|
|
/// treated as having no repository.
|
|
pub(super) fn detect_repo(&mut self, ws: &Path) {
|
|
self.pending_repo = None;
|
|
// A `.git` entry directly in the folder (dir, or a file for linked
|
|
// worktrees/submodules) means this folder is the repo root.
|
|
if ws.join(".git").exists() {
|
|
self.is_repo = true;
|
|
self.repo_root = Some(ws.to_path_buf());
|
|
return;
|
|
}
|
|
match gitsync::repo_root(ws) {
|
|
// A parent directory is a repository — ask before adopting it.
|
|
Some(root) if root != ws => {
|
|
self.is_repo = false;
|
|
self.repo_root = None;
|
|
self.pending_repo = Some(root);
|
|
}
|
|
// `--show-toplevel` reported this very folder (shouldn't happen
|
|
// without a `.git` here, but treat it as an ordinary repo root).
|
|
Some(root) => {
|
|
self.is_repo = true;
|
|
self.repo_root = Some(root);
|
|
}
|
|
None => {
|
|
self.is_repo = false;
|
|
self.repo_root = None;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Adopt the parent repository awaiting confirmation, using it for all git
|
|
/// operations on the current workspace.
|
|
pub(super) fn adopt_pending_repo(&mut self) {
|
|
if let Some(root) = self.pending_repo.take() {
|
|
self.status = format!("Using git repository at {}", root.display());
|
|
self.is_repo = true;
|
|
self.repo_root = Some(root);
|
|
}
|
|
}
|
|
|
|
/// Decline the parent repository; the workspace stays without version
|
|
/// control (the user can still `Init git` to create a nested repo).
|
|
pub(super) fn decline_pending_repo(&mut self) {
|
|
self.pending_repo = None;
|
|
self.is_repo = false;
|
|
self.repo_root = None;
|
|
self.status = "Not using the enclosing git repository".to_string();
|
|
}
|
|
|
|
/// Work out whether the opened folder is a **project root** — a folder
|
|
/// holding the manuscript alongside characters, outline and the rest — and
|
|
/// if so, which of its subfolders is the manuscript.
|
|
///
|
|
/// The test is simply whether it has a child folder named like the
|
|
/// configured manuscript folder (`06-First Draft`), matched leniently by
|
|
/// [`order::dir_matches`]. `None` means the workspace is itself the
|
|
/// manuscript, which is how the app behaved before project mode and remains
|
|
/// the right answer for a plain folder of chapters.
|
|
pub(super) fn detect_manuscript_dir(&self, ws: &Path) -> Option<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())
|
|
}
|
|
|
|
pub(super) fn rename_selected(&mut self) {
|
|
let Some(idx) = self.selected else { return };
|
|
// A path in the box (`part-2/ch-07`) both renames and moves the file.
|
|
let Some(new_name) = sanitize_rel_path(&self.rename_input) else {
|
|
self.status = "Enter a new name".to_string();
|
|
return;
|
|
};
|
|
let Some(old_name) = self.files.get(idx).cloned() else {
|
|
return;
|
|
};
|
|
if new_name == old_name {
|
|
return;
|
|
}
|
|
if self.path_for(&new_name).exists() {
|
|
self.status = format!("{new_name} already exists");
|
|
return;
|
|
}
|
|
// Persist any pending edits under the old name first.
|
|
self.save_current();
|
|
match self.relocate(&old_name, &new_name) {
|
|
Ok(_) => {
|
|
self.files[idx] = new_name.clone();
|
|
self.files = order::tree_order(&self.files);
|
|
self.selected = self.files.iter().position(|f| *f == new_name);
|
|
self.persist_order();
|
|
self.reveal(&new_name);
|
|
self.status = format!("Renamed to {new_name}");
|
|
}
|
|
Err(e) => self.status = format!("Rename failed: {e}"),
|
|
}
|
|
}
|
|
|
|
pub(super) fn git_init(&mut self) {
|
|
// Initialising creates a repository in the workspace itself, which
|
|
// supersedes any enclosing repo we were about to ask about.
|
|
self.pending_repo = None;
|
|
let ws = self.workspace().to_path_buf();
|
|
let outcome = gitsync::init(&ws);
|
|
self.git_log = outcome.log;
|
|
self.show_log = true;
|
|
self.is_repo = gitsync::is_repo(&ws);
|
|
self.repo_root = self.is_repo.then_some(ws);
|
|
self.status = if self.is_repo {
|
|
"Initialised git repository".to_string()
|
|
} else {
|
|
"git init failed (see log)".to_string()
|
|
};
|
|
}
|
|
|
|
pub(super) fn git_sync(&mut self) {
|
|
self.save_current();
|
|
self.persist_order();
|
|
self.persist_titles();
|
|
let msg = format!(
|
|
"Sync manuscript {}",
|
|
chrono_like_timestamp()
|
|
);
|
|
let outcome = gitsync::sync(self.workspace(), &msg);
|
|
self.git_log = outcome.log;
|
|
self.show_log = true;
|
|
self.status = if outcome.ok {
|
|
"Sync complete".to_string()
|
|
} else {
|
|
"Sync finished with errors (see log)".to_string()
|
|
};
|
|
}
|
|
|
|
/// Digits to zero-pad a defaulted chapter number to: the width of the largest
|
|
/// chapter number when padding is enabled, otherwise 1 (no padding).
|
|
pub(super) fn index_pad_width(&self) -> usize {
|
|
if self.config.zero_pad_index {
|
|
self.manuscript_files().len().to_string().len().max(1)
|
|
} else {
|
|
1
|
|
}
|
|
}
|
|
|
|
/// The title a chapter at `idx` would get without a manual override: its
|
|
/// `# Title:` header, or its 1-based position (e.g. "3."). Shown as the hint
|
|
/// in the chapter-title field.
|
|
pub(super) fn auto_title(&self, idx: usize, markdown: &str) -> String {
|
|
let header = crate::preprocess::parse(markdown, &self.config.draft_marker);
|
|
resolve_chapter_title(None, header.title.as_deref(), idx, self.index_pad_width())
|
|
}
|
|
|
|
/// The current file's word-count target (from its `Word Count Target:`
|
|
/// header) paired with its current prose word count, if a target is set.
|
|
/// Prose = the body below the draft marker, so header metadata isn't counted.
|
|
pub(super) fn current_goal(&self) -> Option<(crate::preprocess::WordGoal, usize)> {
|
|
self.selected?;
|
|
let header = crate::preprocess::parse(&self.document(), &self.config.draft_marker);
|
|
let goal = header.goal?;
|
|
Some((goal, count_words(&header.body)))
|
|
}
|
|
|
|
/// Document properties for an export: the configured title, author and
|
|
/// contact details, with the title falling back to the project folder's own
|
|
/// name.
|
|
pub(super) fn doc_meta(&self, chapters: &[Chapter]) -> odt::DocMeta {
|
|
let title = match self.config.manuscript_title.trim() {
|
|
"" => self
|
|
.workspace()
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or("Manuscript")
|
|
.to_string(),
|
|
set => set.to_string(),
|
|
};
|
|
odt::DocMeta {
|
|
title,
|
|
author: self.config.manuscript_author.trim().to_string(),
|
|
contact: self.config.manuscript_contact.clone(),
|
|
title_page: self.config.manuscript_title_page,
|
|
standard_format: self.config.manuscript_standard_format,
|
|
subject: String::new(),
|
|
keywords: String::new(),
|
|
word_count: chapters.iter().map(|c| count_words(&c.markdown)).sum(),
|
|
chapter_count: chapters.len(),
|
|
}
|
|
}
|
|
|
|
/// Export the manuscript as one `.odt` per chapter plus an `.odm` master
|
|
/// that links them, which is the shape the snowflake template expects.
|
|
///
|
|
/// The per-chapter files are ordinary documents and are the useful part.
|
|
/// The master is a shell: LibreOffice does not follow its section links on
|
|
/// load without being asked to update them, so treat it as a starting point
|
|
/// rather than as the assembled book.
|
|
pub(super) fn export_master(&mut self) {
|
|
self.save_current();
|
|
let chapters = self.collect_chapters();
|
|
if chapters.is_empty() {
|
|
self.status = "Nothing to export — the manuscript is empty".to_string();
|
|
return;
|
|
}
|
|
let meta = self.doc_meta(&chapters);
|
|
let out = PathBuf::from(self.export_input.trim()).with_extension("odm");
|
|
if let Some(parent) = out.parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
match odt::export_master(&chapters, &meta, &out) {
|
|
Ok(written) => {
|
|
self.status = format!(
|
|
"Wrote {} chapter file(s) beside {}",
|
|
written.len(),
|
|
out.display()
|
|
);
|
|
}
|
|
Err(e) => self.status = format!("Export failed: {e}"),
|
|
}
|
|
}
|
|
|
|
/// The manuscript as chapters, ready for either export path.
|
|
fn collect_chapters(&self) -> Vec<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()
|
|
}
|
|
|
|
pub(super) fn export_odt(&mut self) {
|
|
self.save_current();
|
|
let marker = self.config.draft_marker.clone();
|
|
let pad_width = self.index_pad_width();
|
|
let mut chapters = Vec::new();
|
|
for (i, name) in self.manuscript_files() {
|
|
let raw = std::fs::read_to_string(self.path_for(name)).unwrap_or_default();
|
|
// Strip comments + the editorial header, and lift out any
|
|
// `# Title:` / `# Slug:` metadata.
|
|
let header = crate::preprocess::parse(&raw, &marker);
|
|
|
|
// Title priority: manual override > `# Title:` metadata > the chapter's
|
|
// 1-based position (e.g. "3."). The body from `parse` is used as-is.
|
|
let title = resolve_chapter_title(
|
|
self.titles.get(name).map(String::as_str),
|
|
header.title.as_deref(),
|
|
i,
|
|
pad_width,
|
|
);
|
|
chapters.push(Chapter {
|
|
title,
|
|
slug: header.slug.clone(),
|
|
markdown: header.body,
|
|
});
|
|
}
|
|
let out = PathBuf::from(self.export_input.trim());
|
|
if let Some(parent) = out.parent() {
|
|
let _ = std::fs::create_dir_all(parent);
|
|
}
|
|
let meta = self.doc_meta(&chapters);
|
|
match odt::export(&chapters, &meta, &out) {
|
|
Ok(_) => {
|
|
self.config.export_path = out.clone();
|
|
self.config.save();
|
|
self.status = format!("Exported {} chapter(s) to {}", chapters.len(), out.display());
|
|
}
|
|
Err(e) => self.status = format!("Export failed: {e}"),
|
|
}
|
|
}
|
|
|
|
/// Open a native folder picker to choose the workspace directory.
|
|
pub(super) fn browse_workspace(&mut self) {
|
|
let start = PathBuf::from(self.workspace_input.trim());
|
|
let mut dialog = rfd::FileDialog::new().set_title("Choose workspace folder");
|
|
if start.is_dir() {
|
|
dialog = dialog.set_directory(&start);
|
|
}
|
|
if let Some(path) = dialog.pick_folder() {
|
|
self.save_current();
|
|
self.workspace_input = path.display().to_string();
|
|
self.config.workspace = path;
|
|
self.config.save();
|
|
self.open_workspace();
|
|
}
|
|
}
|
|
|
|
/// Open a native save dialog to choose the export `.odt` path.
|
|
pub(super) fn browse_export(&mut self) {
|
|
let current = PathBuf::from(self.export_input.trim());
|
|
let mut dialog = rfd::FileDialog::new()
|
|
.set_title("Choose export file")
|
|
.add_filter("OpenDocument Text", &["odt"]);
|
|
if let Some(parent) = current.parent().filter(|p| p.is_dir()) {
|
|
dialog = dialog.set_directory(parent);
|
|
}
|
|
let name = current
|
|
.file_name()
|
|
.and_then(|s| s.to_str())
|
|
.unwrap_or("manuscript.odt");
|
|
if let Some(mut path) = dialog.set_file_name(name).save_file() {
|
|
// Ensure the chosen path ends in .odt even if the user omitted it.
|
|
let has_odt = path
|
|
.extension()
|
|
.and_then(|e| e.to_str())
|
|
.is_some_and(|e| e.eq_ignore_ascii_case("odt"));
|
|
if !has_odt {
|
|
path.set_extension("odt");
|
|
}
|
|
self.export_input = path.display().to_string();
|
|
self.config.export_path = path;
|
|
self.config.save();
|
|
}
|
|
}
|
|
|
|
/// Apply a file-panel drag-and-drop: move the file into the drop's folder on
|
|
/// disk when that changed, then reposition it in the manuscript order.
|
|
pub(super) fn apply_drop(&mut self, drop: FileDrop) {
|
|
let FileDrop { from, to, dir } = drop;
|
|
let Some(old) = self.files.get(from).cloned() else {
|
|
return;
|
|
};
|
|
if parent_dir(&old) == dir {
|
|
self.status = "Reordered".to_string();
|
|
} else {
|
|
let new = join_rel(&dir, base_name(&old));
|
|
if self.path_for(&new).exists() {
|
|
self.status = format!("{new} already exists");
|
|
return;
|
|
}
|
|
// Flush pending edits under the old name before the file moves.
|
|
self.save_current();
|
|
if let Err(e) = self.relocate(&old, &new) {
|
|
self.status = format!("Move failed: {e}");
|
|
return;
|
|
}
|
|
self.files[from] = new;
|
|
self.status = match dir.as_str() {
|
|
"" => format!("Moved {} to the workspace root", base_name(&old)),
|
|
dir => format!("Moved {} into {dir}", base_name(&old)),
|
|
};
|
|
}
|
|
self.reorder(from, to);
|
|
}
|
|
|
|
/// Move the file at `from` to flat position `to`, then re-normalise into
|
|
/// folder-tree order so the panel and the export stay in step.
|
|
pub(super) fn reorder(&mut self, from: usize, mut to: usize) {
|
|
if from >= self.files.len() {
|
|
return;
|
|
}
|
|
// Remember the selected file by name so selection follows the move.
|
|
let selected_name = self.selected.and_then(|i| self.files.get(i)).cloned();
|
|
|
|
let item = self.files.remove(from);
|
|
if from < to {
|
|
to -= 1;
|
|
}
|
|
to = to.min(self.files.len());
|
|
self.files.insert(to, item);
|
|
self.files = order::tree_order(&self.files);
|
|
self.persist_order();
|
|
|
|
if let Some(name) = selected_name {
|
|
self.selected = self.files.iter().position(|n| *n == name);
|
|
}
|
|
}
|
|
|
|
/// Settings dialog for the markdown seeded into template-backed new files.
|
|
pub(super) fn template_settings_window(&mut self, ctx: &egui::Context) {
|
|
let mut open = self.show_template_settings;
|
|
let mut close_clicked = false;
|
|
egui::Window::new("New-file template")
|
|
.open(&mut open)
|
|
.resizable(true)
|
|
.collapsible(false)
|
|
.default_width(430.0)
|
|
.show(ctx, |ui| {
|
|
let mut save_now = false;
|
|
ui.label(
|
|
egui::RichText::new(
|
|
"Seeded into files made with “+ New from template” in the file list.",
|
|
)
|
|
.small()
|
|
.weak(),
|
|
);
|
|
ui.add_space(4.0);
|
|
let r = ui.add(
|
|
egui::TextEdit::multiline(&mut self.config.new_file_template)
|
|
.code_editor()
|
|
.desired_width(f32::INFINITY)
|
|
.desired_rows(12),
|
|
);
|
|
save_now |= r.lost_focus();
|
|
|
|
ui.add_space(4.0);
|
|
ui.label(egui::RichText::new("Placeholders").strong());
|
|
egui::Grid::new("template_placeholders")
|
|
.num_columns(2)
|
|
.spacing([10.0, 2.0])
|
|
.show(ui, |ui| {
|
|
for (token, meaning) in [
|
|
("{{name}}", "the file stem, e.g. untitled-3"),
|
|
("{{marker}}", "the draft marker set in the toolbar"),
|
|
("{{date}}", "today's date (UTC), as YYYY-MM-DD"),
|
|
] {
|
|
ui.label(egui::RichText::new(token).monospace());
|
|
ui.label(egui::RichText::new(meaning).weak());
|
|
ui.end_row();
|
|
}
|
|
});
|
|
|
|
ui.add_space(4.0);
|
|
ui.label(
|
|
egui::RichText::new(
|
|
"Leave the box empty to go back to the built-in default.",
|
|
)
|
|
.small()
|
|
.weak(),
|
|
);
|
|
ui.separator();
|
|
ui.horizontal(|ui| {
|
|
if ui.button("Close").clicked() {
|
|
close_clicked = true;
|
|
}
|
|
if ui
|
|
.button("Reset to default")
|
|
.on_hover_text("Replace the box with the built-in template")
|
|
.clicked()
|
|
{
|
|
self.config.new_file_template =
|
|
crate::config::default_new_file_template();
|
|
save_now = true;
|
|
}
|
|
});
|
|
if save_now {
|
|
self.config.save();
|
|
}
|
|
});
|
|
|
|
let now_open = open && !close_clicked;
|
|
if self.show_template_settings && !now_open {
|
|
self.config.save();
|
|
}
|
|
self.show_template_settings = now_open;
|
|
}
|
|
}
|
|
|
|
/// Index of the first file the panel lists, which is the one to open a
|
|
/// workspace on.
|
|
///
|
|
/// In a project the panel shows the manuscript, so landing on `files[0]` would
|
|
/// open whatever sorts first across the whole project — a scratch-pad note,
|
|
/// typically — and in a file the user cannot see in the tree. Reference files
|
|
/// are still the fallback, for a project whose manuscript folder is empty.
|
|
/// Which folder to treat as the project root, from state the app already holds.
|
|
///
|
|
/// Split out from [`App::project_root`] so the choice can be exercised without
|
|
/// standing up a whole `App`.
|
|
fn project_root_of<'a>(
|
|
workspace: &'a Path,
|
|
in_project_mode: bool,
|
|
repo_root: Option<&'a Path>,
|
|
pending_repo: Option<&'a Path>,
|
|
) -> &'a Path {
|
|
if in_project_mode {
|
|
return workspace;
|
|
}
|
|
repo_root.or(pending_repo).unwrap_or(workspace)
|
|
}
|
|
|
|
pub(super) fn first_listed(files: &[String], manuscript_dir: Option<&str>) -> Option<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, ¤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",
|
|
"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);
|
|
}
|
|
}
|