41c3f088cd
Two features that arrived together, since the second depends on the first to show what it generates. Nested folders -------------- `App::files` now holds workspace-relative paths (`part-1/ch-03.md`) rather than bare names, and the workspace scan recurses eight levels, skipping dot-directories, `target/` and `node_modules/`. `order::tree_order` normalises the flat order so every folder's files are contiguous and each folder sits where its earliest-ordered file put it — which is what keeps the panel and the export in agreement: the export concatenates the tree read top to bottom. The panel draws a collapsible tree. Dragging within a folder reorders as before; dropping onto a folder header, or among another folder's files, moves the file on disk and carries its title override, session word baseline and cached header info with it. Emptied folders are pruned. New files take a path (`part-1/ch-01`) to create folders, and the Rename box now holds the whole relative path, so editing its folder part moves the file. File ▸ New project ------------------ Scaffolds a project from a cookiecutter template and opens its drafting subfolder (`06-First Draft`) as the workspace. `cookiecutter.rs` resolves the executable from PATH and the usual per-user Python prefixes — a desktop launcher inherits neither a conda PATH nor the tools a template's hooks shell out to, so the resolved binary's directory is prepended for the child — builds the non-interactive command line, and identifies the result by diffing the output directory, which works whatever a template names its root. Generation runs off the UI thread because hooks can reach the network. Settings ▸ New project… covers the template path, the subfolder to open, the cookiecutter path, a hooks toggle, and the Gitea credentials passed to hooks as GITEA_URL / GITEA_USER / GITEA_TOKEN. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011ZGoPiDuZ7vmryNCJWjYSD
880 lines
34 KiB
Rust
880 lines
34 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
|
|
}
|
|
|
|
/// (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;
|
|
}
|
|
self.files = order::resolve_order(&ws);
|
|
self.titles = order::read_titles(&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.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.autocomplete = None;
|
|
self.collapsed.clear();
|
|
if !self.files.is_empty() {
|
|
self.select(0);
|
|
}
|
|
self.persist_order();
|
|
self.status = format!("{} file(s) in {}", self.files.len(), ws.display());
|
|
}
|
|
|
|
/// 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();
|
|
}
|
|
|
|
/// 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/prose),
|
|
/// for files that carry anything worth showing in the list.
|
|
pub(super) fn snapshot_file_meta(&self) -> HashMap<String, FileMeta> {
|
|
self.files
|
|
.iter()
|
|
.filter_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))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
/// 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) {
|
|
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);
|
|
}
|
|
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;
|
|
let Some(name) = self.files.get(idx).cloned() else {
|
|
return;
|
|
};
|
|
let path = self.path_for(&name);
|
|
self.buffer = std::fs::read_to_string(&path).unwrap_or_default();
|
|
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.dirty = false;
|
|
if !self.files.is_empty() {
|
|
self.select(idx.min(self.files.len() - 1));
|
|
}
|
|
self.status = format!("Deleted {name}");
|
|
}
|
|
Err(e) => self.status = format!("Delete failed: {e}"),
|
|
}
|
|
}
|
|
}
|
|
self.pending_delete = false;
|
|
}
|
|
|
|
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.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.buffer, &self.config.draft_marker);
|
|
let goal = header.goal?;
|
|
Some((goal, count_words(&header.body)))
|
|
}
|
|
|
|
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() {
|
|
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);
|
|
}
|
|
match odt::export(&chapters, &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;
|
|
}
|
|
}
|
|
|
|
/// 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::*;
|
|
|
|
#[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:?}");
|
|
}
|
|
|
|
/// 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);
|
|
}
|
|
}
|