//! **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; 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; } }