Add nested folders to the file panel and File ▸ New project
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
This commit is contained in:
@@ -0,0 +1,450 @@
|
||||
//! **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.
|
||||
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: its configured drafting
|
||||
/// subfolder when the template produced one, otherwise the project root.
|
||||
fn open_project(&mut self, project: &Path) {
|
||||
let subdir = self.config.project_open_subdir.trim();
|
||||
let (workspace, note) = match subdir {
|
||||
"" => (project.to_path_buf(), String::new()),
|
||||
sub if project.join(sub).is_dir() => (project.join(sub), String::new()),
|
||||
sub => (
|
||||
project.to_path_buf(),
|
||||
format!(" (no {sub} folder in it, opened the project root)"),
|
||||
),
|
||||
};
|
||||
self.save_current();
|
||||
self.workspace_input = workspace.display().to_string();
|
||||
self.config.workspace = workspace;
|
||||
// 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 its {subdir} folder as the workspace."
|
||||
))
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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("Open subfolder:");
|
||||
let r = ui.add(
|
||||
egui::TextEdit::singleline(&mut self.config.project_open_subdir)
|
||||
.hint_text("06-First Draft")
|
||||
.desired_width(280.0),
|
||||
);
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user