//! **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. 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.project_template.clone(); let Some(bin) = crate::cookiecutter::resolve_binary( &self.config.cookiecutter_bin, dirs::home_dir().as_deref(), ) else { self.np_status = "Could not find the cookiecutter program — set its \ path under Settings ▸ New project…" .to_string(); 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(); let request = crate::cookiecutter::Request { bin, template, output_dir: parent, vars: vec![ crate::cookiecutter::Var::new("project_name", &name), crate::cookiecutter::Var::new("author", self.np_author.trim()), crate::cookiecutter::Var::new("description", self.np_description.trim()), ], env: self.hook_env(), run_hooks: self.config.project_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 _ = tx.send(crate::cookiecutter::generate(&request)); 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( "The template's hooks will run. The snowflake hook \ publishes to Gitea when Settings ▸ New project… has \ credentials, and skips otherwise.", ) .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(); ui.label( egui::RichText::new( "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(); 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("06-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 “06-First Draft”.", ); save_now |= r.lost_focus(); ui.end_row(); ui.label("cookiecutter path:"); let r = ui.add( egui::TextEdit::singleline(&mut self.config.cookiecutter_bin) .hint_text("blank = search PATH and conda prefixes") .desired_width(280.0), ); save_now |= r.lost_focus(); ui.end_row(); }); let found = crate::cookiecutter::resolve_binary( &self.config.cookiecutter_bin, dirs::home_dir().as_deref(), ); ui.label( egui::RichText::new(match &found { Some(path) => format!("✔ {}", path.display()), None => "✖ cookiecutter not found".to_string(), }) .small() .weak(), ); ui.add_space(6.0); if ui .checkbox( &mut self.config.project_run_hooks, "Run the template's hooks", ) .on_hover_text( "Templates can run scripts after generating. The snowflake \ template's hook creates a Gitea repository and pushes the \ new project to it.", ) .changed() { save_now = true; } ui.add_space(6.0); ui.label(egui::RichText::new("Gitea (used by the template's hook)").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( "Passed to the hook as GITEA_URL / GITEA_USER / GITEA_TOKEN. \ Leave blank and the hook skips publishing — the project is \ still created. The token 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.project_template.is_dir() { dialog = dialog.set_directory(&self.config.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; } }