Add project-mode tooling: characters, outline, diff and revision status
Rounds out project mode, where the workspace is the project root and one subfolder holds the manuscript proper: - Characters and Outline windows, backed by new `characters` and `outline` modules that read the cast from character sheets and measure how much of the snowflake outline is actually written. - Edit ▸ Changes… diffs the open file against its last committed version. - Revision status, per-file and project word counts, an archive action and hidden folders in the file panel. - Chapter-file export alongside the ODT master, richer header parsing, and a project word list for names and invented terms. - The export path now follows the workspace: opening a project points it at that project root, keeping a file name you chose yourself and re-deriving one that merely echoed the folder it sat in. - Clicking an issue in the grammar/spelling panel takes the editor to it, selecting the words and centring them; applying a suggestion jumps to the rewritten text as well. README covers the new windows and workflows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017bSn3Xijp8GofZVUnRX4oq
This commit is contained in:
+498
-23
@@ -17,26 +17,59 @@ impl App {
|
||||
self.status = format!("Cannot create workspace: {e}");
|
||||
return;
|
||||
}
|
||||
self.files = order::resolve_order(&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.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 !self.files.is_empty() {
|
||||
self.select(0);
|
||||
if let Some(idx) = first_listed(&self.files, self.manuscript_dir.as_deref()) {
|
||||
self.select(idx);
|
||||
}
|
||||
self.persist_order();
|
||||
self.status = format!("{} file(s) in {}", self.files.len(), ws.display());
|
||||
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.
|
||||
@@ -94,6 +127,39 @@ impl App {
|
||||
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
|
||||
@@ -105,19 +171,56 @@ impl App {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Read every file and return its cached header info (slug/POV/goal/prose),
|
||||
/// for files that carry anything worth showing in the list.
|
||||
/// 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()
|
||||
.filter_map(|name| {
|
||||
.map(|name| {
|
||||
let text = std::fs::read_to_string(self.path_for(name)).unwrap_or_default();
|
||||
let meta = FileMeta::from_markdown(&text, &self.config.draft_marker);
|
||||
meta.has_display().then(|| (name.clone(), meta))
|
||||
(
|
||||
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);
|
||||
}
|
||||
@@ -146,24 +249,97 @@ impl App {
|
||||
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.buffer) {
|
||||
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.buffer, &self.config.draft_marker);
|
||||
if meta.has_display() {
|
||||
self.file_meta.insert(name, meta);
|
||||
} else {
|
||||
self.file_meta.remove(&name);
|
||||
}
|
||||
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}"),
|
||||
@@ -193,7 +369,9 @@ impl App {
|
||||
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;
|
||||
@@ -316,9 +494,12 @@ impl App {
|
||||
prune_empty_dirs(self.workspace(), parent_dir(&name));
|
||||
self.selected = None;
|
||||
self.buffer.clear();
|
||||
self.header_stash = None;
|
||||
self.dirty = false;
|
||||
if !self.files.is_empty() {
|
||||
self.select(idx.min(self.files.len() - 1));
|
||||
if let Some(next) =
|
||||
nearest_listed(&self.files, self.manuscript_dir.as_deref(), idx)
|
||||
{
|
||||
self.select(next);
|
||||
}
|
||||
self.status = format!("Deleted {name}");
|
||||
}
|
||||
@@ -329,6 +510,54 @@ impl App {
|
||||
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.
|
||||
@@ -400,7 +629,7 @@ impl App {
|
||||
/// 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.files.len().to_string().len().max(1)
|
||||
self.manuscript_files().len().to_string().len().max(1)
|
||||
} else {
|
||||
1
|
||||
}
|
||||
@@ -419,17 +648,93 @@ impl App {
|
||||
/// 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.buffer, &self.config.draft_marker);
|
||||
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 and author, 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(),
|
||||
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.files.iter().enumerate() {
|
||||
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.
|
||||
@@ -453,7 +758,8 @@ impl App {
|
||||
if let Some(parent) = out.parent() {
|
||||
let _ = std::fs::create_dir_all(parent);
|
||||
}
|
||||
match odt::export(&chapters, &out) {
|
||||
let meta = self.doc_meta(&chapters);
|
||||
match odt::export(&chapters, &meta, &out) {
|
||||
Ok(_) => {
|
||||
self.config.export_path = out.clone();
|
||||
self.config.save();
|
||||
@@ -640,6 +946,73 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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
|
||||
@@ -801,6 +1174,49 @@ mod tests {
|
||||
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}"));
|
||||
@@ -876,4 +1292,63 @@ mod tests {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user