0f144737a5
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>
632 lines
26 KiB
Rust
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;
|
|
}
|
|
}
|