Files
md-manuscript/src/app/project.rs
T
landon 0f144737a5 Finish bundled-template migration; simplify frontmatter; de-duplicate toolbar/menu
Completes the move off the external cookiecutter tool: drops
src/cookiecutter.rs and the cookiecutter_bin/Template-path setting now
that scaffolding always runs through the built-in MiniJinja engine,
and rewrites default_project_template() to walk up from the executable
without panicking when the bundled template can't be found. Rounds
out the snowflake template with the remaining 06-Revisions,
07-Final, 08-Covers and 09-Archive folders.

Simplifies the scene/chapter YAML frontmatter (drops type/word_count_min/
word_count_max in favor of status + a flat characters list) and updates
the new-file template, header parsing, outline, file list and Story
Bible editor to match.

Separately, de-duplicates the toolbar and menu bar: settings that only
existed as always-visible toolbar controls (draft marker, zero-pad,
spell-check toggle, dictionary picker) move into their Settings
windows, toolbar buttons that exactly duplicated a menu item are
dropped, and toolbar-only actions with no menu path (open project
folder, git sync/init) gain one under File. Also fixes two ambiguous
menu labels: the Story Bible editor now reads "Story Bible editor…"
next to "Story Bible…" in Tools instead of sharing a label with Edit,
and Settings' "New project…" is now "New-project settings…" so it
doesn't read like File's actual create action.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 10:35:52 -05:00

632 lines
26 KiB
Rust

//! **File ▸ New project…**: scaffolding a manuscript project from a
//! cookiecutter template, then opening its drafting subfolder as the workspace.
//!
//! Generation runs on a worker thread — the template's hooks can reach the
//! network — and the UI polls for the result, so the editor stays responsive.
use super::*;
/// Result of a background generation: the project directory, or a message.
pub(super) type ProjectMsg = Result<PathBuf, String>;
impl App {
/// Open the new-project dialog, prefilled from the last one.
pub(super) fn open_new_project(&mut self) {
self.np_name.clear();
self.np_description.clear();
self.np_author = self.config.project_author.clone();
self.np_parent = self.config.projects_dir.display().to_string();
self.np_status.clear();
self.show_new_project = true;
}
/// The environment a template's hooks are given. Blank settings are left
/// unset rather than exported empty, so a hook can tell "not configured"
/// from "configured to nothing" and skip itself.
#[allow(dead_code)]
fn hook_env(&self) -> Vec<(String, String)> {
[
("GITEA_URL", self.config.gitea_url.trim()),
("GITEA_USER", self.config.gitea_user.trim()),
("GITEA_TOKEN", self.config.gitea_token.trim()),
]
.into_iter()
.filter(|(_, value)| !value.is_empty())
.map(|(key, value)| (key.to_string(), value.to_string()))
.collect()
}
/// Validate the dialog and kick off generation on a worker thread.
fn start_new_project(&mut self, ctx: &egui::Context) {
if self.np_rx.is_some() {
return; // one already running
}
let name = self.np_name.trim().to_string();
if name.is_empty() {
self.np_status = "Give the project a name.".to_string();
return;
}
// The name becomes a directory name, so the separators that would make
// it a path have to go.
if name.contains('/') || name.contains('\\') {
self.np_status = "The project name cannot contain / or \\.".to_string();
return;
}
let parent = PathBuf::from(self.np_parent.trim());
if self.np_parent.trim().is_empty() {
self.np_status = "Choose a folder to create the project in.".to_string();
return;
}
if parent.join(&name).exists() {
self.np_status = format!("{} already exists.", parent.join(&name).display());
return;
}
let template = self.config.effective_project_template();
// Check that the template directory exists
if !template.is_dir() {
self.np_status = format!("Template directory {} does not exist.", template.display());
return;
}
// Remember the choices that are worth prefilling next time.
self.config.project_author = self.np_author.trim().to_string();
self.config.projects_dir = parent.clone();
self.config.save();
// Gather Gitea settings for potential hook execution
let gitea_url = self.config.gitea_url.clone();
let gitea_user = self.config.gitea_user.clone();
let gitea_token = self.config.gitea_token.clone();
let gitea_private = self.config.gitea_private;
let gitea_use_ssh = self.config.gitea_use_ssh;
let run_hooks = self.config.project_run_hooks;
let config = crate::project_template::RenderConfig {
template_dir: template,
output_dir: parent,
variables: crate::project_template::TemplateVariables::new(
&name,
self.np_author.trim(),
self.np_description.trim(),
),
run_hooks,
};
let (tx, rx) = std::sync::mpsc::channel();
self.np_rx = Some(rx);
self.np_status = format!("Creating {name}…");
let ctx = ctx.clone();
std::thread::spawn(move || {
let result = crate::project_template::render_template(&config);
let project = match result {
Ok(p) => p,
Err(e) => {
let _ = tx.send(Err(e));
return;
}
};
// If hooks are enabled and Gitea is configured, create the repo and set up git
if run_hooks && !gitea_url.is_empty() && !gitea_user.is_empty() && !gitea_token.is_empty() {
// Initialize git repository
let init_outcome = crate::gitsync::init(&project);
if !init_outcome.ok {
let _ = tx.send(Err(format!("Failed to initialize git: {}", init_outcome.log)));
return;
}
// Create the repository on Gitea
let repo = crate::gitea::NewRepo {
name: name.clone(),
description: config.variables.description.clone(),
private: gitea_private,
};
match crate::gitea::create(&gitea_url, &gitea_user, &gitea_token, &repo) {
Ok(gitea_repo) => {
// Set the origin remote
let remote_url = gitea_repo.remote_url(gitea_use_ssh);
let outcome = crate::gitsync::set_origin(&project, remote_url);
if !outcome.ok {
// Log error but don't fail - the project was created
eprintln!("Failed to set origin: {}", outcome.log);
}
}
Err(e) => {
// Log error but don't fail - the project was created
eprintln!("Failed to create Gitea repo: {}", e);
}
}
}
let _ = tx.send(Ok(project));
ctx.request_repaint();
});
}
/// Pick up a finished generation and open the new project.
pub(super) fn poll_new_project(&mut self) {
let received = self.np_rx.as_ref().and_then(|rx| rx.try_recv().ok());
let Some(result) = received else { return };
self.np_rx = None;
match result {
Ok(project) => {
self.show_new_project = false;
self.np_status.clear();
self.open_project(&project);
}
Err(e) => self.np_status = format!("✖ {e}"),
}
}
/// Point the workspace at a generated project. The project *root* is opened
/// rather than the drafting folder: the app recognises the layout and treats
/// the drafting folder as the manuscript, keeping the characters, outline and
/// the rest reachable in the same tree.
fn open_project(&mut self, project: &Path) {
let subdir = self.config.project_open_subdir.trim();
let note = if subdir.is_empty() || project.join(subdir).is_dir() {
String::new()
} else {
format!(" (no {subdir} folder in it, so nothing is marked as the manuscript)")
};
self.save_current();
self.workspace_input = project.display().to_string();
self.config.workspace = project.to_path_buf();
// Export alongside the new project rather than into the previous one.
self.config.export_path = project.join(format!(
"{}.odt",
project
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("manuscript")
));
self.export_input = self.config.export_path.display().to_string();
self.config.save();
self.open_workspace();
let name = project
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("project");
self.status = format!("Created {name}{note} — {}", self.status);
}
/// The new-project dialog.
pub(super) fn new_project_window(&mut self, ctx: &egui::Context) {
let mut open = self.show_new_project;
let mut close = false;
let mut create = false;
let mut browse = false;
let running = self.np_rx.is_some();
egui::Window::new("New project")
.open(&mut open)
.resizable(false)
.collapsible(false)
.default_width(430.0)
.show(ctx, |ui| {
ui.add_enabled_ui(!running, |ui| {
egui::Grid::new("new_project_grid")
.num_columns(2)
.spacing([10.0, 8.0])
.show(ui, |ui| {
ui.label("Name:");
ui.add(
egui::TextEdit::singleline(&mut self.np_name)
.hint_text("The Winter Gate")
.desired_width(280.0),
);
ui.end_row();
ui.label("Author:");
ui.add(
egui::TextEdit::singleline(&mut self.np_author)
.desired_width(280.0),
);
ui.end_row();
ui.label("Description:");
ui.add(
egui::TextEdit::singleline(&mut self.np_description)
.hint_text("A short description of the project.")
.desired_width(280.0),
);
ui.end_row();
ui.label("Create in:");
ui.horizontal(|ui| {
ui.add(
egui::TextEdit::singleline(&mut self.np_parent)
.desired_width(240.0),
);
if ui.button("📂").on_hover_text("Choose folder").clicked() {
browse = true;
}
});
ui.end_row();
});
ui.add_space(4.0);
let target = PathBuf::from(self.np_parent.trim())
.join(self.np_name.trim())
.display()
.to_string();
ui.label(
egui::RichText::new(format!("Creates: {target}"))
.small()
.weak()
.monospace(),
);
let subdir = self.config.project_open_subdir.trim();
if !subdir.is_empty() {
ui.label(
egui::RichText::new(format!(
"Then opens the project, with {subdir} as the manuscript."
))
.small()
.weak(),
);
}
if self.config.project_run_hooks {
ui.label(
egui::RichText::new(
"Project will be published to Gitea when Settings ▸ New project… \
has credentials configured.",
)
.small()
.weak(),
);
}
});
if running {
ui.add_space(4.0);
ui.horizontal(|ui| {
ui.spinner();
ui.label("Working…");
});
}
if !self.np_status.is_empty() {
ui.add_space(4.0);
ui.label(egui::RichText::new(&self.np_status).weak());
}
ui.separator();
ui.horizontal(|ui| {
if ui
.add_enabled(!running, egui::Button::new("Create"))
.clicked()
{
create = true;
}
if ui
.add_enabled(!running, egui::Button::new("Cancel"))
.clicked()
{
close = true;
}
});
});
self.show_new_project = open && !close;
if browse {
let mut dialog = rfd::FileDialog::new().set_title("Create the project in…");
let start = PathBuf::from(self.np_parent.trim());
if start.is_dir() {
dialog = dialog.set_directory(&start);
}
if let Some(path) = dialog.pick_folder() {
self.np_parent = path.display().to_string();
}
}
if create {
self.start_new_project(ctx);
}
}
/// Title, author and contact details written into exported documents, plus
/// whether an export opens with a title page carrying them.
pub(super) fn manuscript_settings_window(&mut self, ctx: &egui::Context) {
let mut open = self.show_manuscript_settings;
let mut close = false;
egui::Window::new("Manuscript details")
.open(&mut open)
.resizable(false)
.collapsible(false)
.default_width(380.0)
.show(ctx, |ui| {
let mut save_now = false;
egui::Grid::new("manuscript_details_grid")
.num_columns(2)
.spacing([10.0, 8.0])
.show(ui, |ui| {
ui.label("Title:");
// Resolved first: the field borrows `self.config` mutably.
let fallback = self
.workspace()
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("Manuscript")
.to_string();
let r = ui
.add(
egui::TextEdit::singleline(&mut self.config.manuscript_title)
.hint_text(fallback)
.desired_width(240.0),
)
.on_hover_text("Blank uses the project folder's name");
save_now |= r.lost_focus();
ui.end_row();
ui.label("Author:");
let r = ui.add(
egui::TextEdit::singleline(&mut self.config.manuscript_author)
.desired_width(240.0),
);
save_now |= r.lost_focus();
ui.end_row();
ui.label("Contact:");
let r = ui
.add(
egui::TextEdit::multiline(&mut self.config.manuscript_contact)
.hint_text("Address, email, phone — a line each")
.desired_width(240.0)
.desired_rows(4),
)
.on_hover_text(
"Shown under your name on the title page, laid out \
line for line as typed",
);
save_now |= r.lost_focus();
ui.end_row();
});
ui.separator();
let r = ui
.checkbox(
&mut self.config.manuscript_title_page,
"Begin exports with a title page",
)
.on_hover_text(
"A page of its own carrying the title, the author and the \
contact details, with the first chapter starting after it",
);
save_now |= r.changed();
let r = ui
.checkbox(
&mut self.config.manuscript_standard_format,
"Standard manuscript format",
)
.on_hover_text(
"What an agent or editor expects a submission in: 12pt \
Courier, double-spaced, half-inch paragraph indents, a \
Surname / Title / page header, chapters opening a third \
of the way down, and # for a scene break",
);
save_now |= r.changed();
let r = ui
.checkbox(
&mut self.config.manuscript_screenplay,
"Screenplay (Fountain)",
)
.on_hover_text(
"Read each file's draft as Fountain rather than markdown \
and export the whole manuscript as a paginated \
screenplay PDF: 12pt Courier on US Letter, scene \
headings and dialogue at their standard indents, \
(MORE) and (CONT'D) across page breaks",
);
save_now |= r.changed();
ui.label(
egui::RichText::new(if self.config.manuscript_screenplay {
"Standard manuscript format and the title page above still \
apply to the title page; the script itself is always set \
in screenplay format. Files keep their usual editorial \
header — only the draft below the marker is Fountain."
} else {
"The title and author are also written into exported .odt \
files as their document properties, which is what a word \
processor shows under File ▸ Properties."
})
.small()
.weak(),
);
ui.separator();
ui.horizontal(|ui| {
ui.label("Draft marker:").on_hover_text(
"Text above this line in each file (its header) is dropped on \
export; # Title: / # Slug: lines become the chapter heading \
and caption. Leave blank to export files whole.",
);
let r = ui.add(
egui::TextEdit::singleline(&mut self.config.draft_marker)
.desired_width(140.0),
);
save_now |= r.lost_focus();
});
let r = ui
.checkbox(&mut self.config.zero_pad_index, "Zero-pad chapter #")
.on_hover_text(
"When a chapter title defaults to its number, pad it with \
leading zeros (e.g. 03. of 12).",
);
save_now |= r.changed();
ui.separator();
if ui.button("Close").clicked() {
close = true;
}
if save_now {
self.config.save();
}
});
let now_open = open && !close;
if self.show_manuscript_settings && !now_open {
self.config.save();
}
self.show_manuscript_settings = now_open;
}
/// Settings for **File ▸ New project…**: which template to render, how to
/// run it, and the credentials its hooks read.
pub(super) fn project_settings_window(&mut self, ctx: &egui::Context) {
let mut open = self.show_project_settings;
let mut close = false;
let mut browse_template = false;
egui::Window::new("New-project settings")
.open(&mut open)
.resizable(false)
.collapsible(false)
.default_width(470.0)
.show(ctx, |ui| {
let mut save_now = false;
ui.label(egui::RichText::new("Template").strong());
ui.horizontal(|ui| {
let mut template = self.config.project_template.display().to_string();
let r = ui.add(
egui::TextEdit::singleline(&mut template)
.desired_width(330.0),
);
if r.changed() {
self.config.project_template = PathBuf::from(template.trim());
}
save_now |= r.lost_focus();
if ui.button("📂").on_hover_text("Choose folder").clicked() {
browse_template = true;
}
});
let template_ok = self
.config
.project_template
.join("cookiecutter.json")
.is_file();
ui.label(
egui::RichText::new(if template_ok {
"✔ cookiecutter.json found".to_string()
} else {
"✖ no cookiecutter.json in that folder".to_string()
})
.small()
.weak(),
);
ui.add_space(6.0);
egui::Grid::new("project_settings_grid")
.num_columns(2)
.spacing([10.0, 8.0])
.show(ui, |ui| {
ui.label("Manuscript folder:");
let r = ui
.add(
egui::TextEdit::singleline(
&mut self.config.project_open_subdir,
)
.hint_text("05-First Draft")
.desired_width(280.0),
)
.on_hover_text(
"Which folder of a project holds the book. Its files \
are ordered, numbered and exported; the rest of the \
project is reference. A leading number is optional, \
so \"First Draft\" also matches \"05-First Draft\".",
);
save_now |= r.lost_focus();
ui.end_row();
});
ui.label(
egui::RichText::new("✔ Built-in MiniJinja templating - no external tool needed")
.small()
.weak(),
);
ui.add_space(6.0);
if ui
.checkbox(
&mut self.config.project_run_hooks,
"Publish to Gitea after creation",
)
.on_hover_text(
"When enabled and Gitea credentials are configured below, \
a new repository will be created and the project pushed \
to it.",
)
.changed()
{
save_now = true;
}
ui.add_space(6.0);
ui.label(egui::RichText::new("Gitea").strong());
egui::Grid::new("gitea_grid")
.num_columns(2)
.spacing([10.0, 8.0])
.show(ui, |ui| {
ui.label("Server URL:");
let r = ui.add(
egui::TextEdit::singleline(&mut self.config.gitea_url)
.hint_text("https://gitea.example.com")
.desired_width(280.0),
);
save_now |= r.lost_focus();
ui.end_row();
ui.label("User:");
let r = ui.add(
egui::TextEdit::singleline(&mut self.config.gitea_user)
.desired_width(280.0),
);
save_now |= r.lost_focus();
ui.end_row();
ui.label("Token:");
let r = ui.add(
egui::TextEdit::singleline(&mut self.config.gitea_token)
.password(true)
.desired_width(280.0),
);
save_now |= r.lost_focus();
ui.end_row();
});
ui.label(
egui::RichText::new(
"Used two ways: the app creates repositories with them from \
Settings ▸ Git remote, and passes them to a template's hooks \
as GITEA_URL / GITEA_USER / GITEA_TOKEN. Leave them blank and \
both simply skip — the project is still created. The token \
needs write access to repositories, and is stored in this \
app's config file in plain text.",
)
.small()
.weak(),
);
ui.separator();
if ui.button("Close").clicked() {
close = true;
}
if save_now {
self.config.save();
}
});
if browse_template {
let mut dialog = rfd::FileDialog::new().set_title("Choose a cookiecutter template");
if self.config.effective_project_template().is_dir() {
dialog = dialog.set_directory(&self.config.effective_project_template());
}
if let Some(path) = dialog.pick_folder() {
self.config.project_template = path;
self.config.save();
}
}
let now_open = open && !close;
if self.show_project_settings && !now_open {
self.config.save();
}
self.show_project_settings = now_open;
}
}