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:
2026-08-23 15:18:22 -05:00
parent b71ffc4f20
commit 41c3f088cd
12 changed files with 2243 additions and 148 deletions
+120
View File
@@ -68,6 +68,69 @@ pub struct Config {
/// and `{{date}}` to today's UTC date. Blank = the built-in default.
#[serde(default = "default_new_file_template")]
pub new_file_template: String,
/// Directory holding the cookiecutter template used by **File ▸ New
/// project…** (the folder containing `cookiecutter.json`).
#[serde(default = "default_project_template")]
pub project_template: PathBuf,
/// Path to the `cookiecutter` executable. Blank means look on `PATH` and in
/// the usual per-user Python prefixes.
#[serde(default)]
pub cookiecutter_bin: String,
/// Subfolder of a freshly generated project to open as the workspace. Blank
/// (or a folder the template did not create) opens the project root.
#[serde(default = "default_project_open_subdir")]
pub project_open_subdir: String,
/// Whether to run the template's pre/post-generation hooks. The snowflake
/// template's post-gen hook publishes the project to Gitea, so this decides
/// whether creating a project also creates a remote repository.
#[serde(default = "default_project_run_hooks")]
pub project_run_hooks: bool,
/// Directory new projects are created in; remembered between runs.
#[serde(default = "default_projects_dir")]
pub projects_dir: PathBuf,
/// Author prefilled into the new-project dialog.
#[serde(default)]
pub project_author: String,
/// Base URL of the Gitea server, passed to a template's hooks as
/// `GITEA_URL`. Empty leaves the variable unset, which the snowflake hook
/// treats as "do not publish".
#[serde(default)]
pub gitea_url: String,
/// Gitea account name, passed to hooks as `GITEA_USER`.
#[serde(default)]
pub gitea_user: String,
/// Gitea API token, passed to hooks as `GITEA_TOKEN`. Stored in plain text,
/// like the Mistral key.
#[serde(default)]
pub gitea_token: String,
}
/// Default cookiecutter template for **File ▸ New project…**.
pub fn default_project_template() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("Documents")
.join("Cookiecutters")
.join("snowflake")
}
/// Default subfolder opened after generating a project: the snowflake
/// template's drafting folder, where the chapter files live.
pub fn default_project_open_subdir() -> String {
"06-First Draft".to_string()
}
/// Templates ship hooks because they are meant to run; honouring them is the
/// default, and an unconfigured hook is expected to skip itself.
pub fn default_project_run_hooks() -> bool {
true
}
/// Default parent directory for new projects.
pub fn default_projects_dir() -> PathBuf {
dirs::document_dir().unwrap_or_else(|| {
dirs::home_dir().unwrap_or_else(|| PathBuf::from("."))
})
}
/// Default new-file template: the editorial header fields this app already
@@ -154,6 +217,15 @@ impl Default for Config {
mistral_model: default_mistral_model(),
mistral_base_url: default_mistral_base_url(),
new_file_template: default_new_file_template(),
project_template: default_project_template(),
cookiecutter_bin: String::new(),
project_open_subdir: default_project_open_subdir(),
project_run_hooks: default_project_run_hooks(),
projects_dir: default_projects_dir(),
project_author: String::new(),
gitea_url: String::new(),
gitea_user: String::new(),
gitea_token: String::new(),
}
}
}
@@ -292,4 +364,52 @@ mod tests {
let back: Config = serde_json::from_str(&text).expect("deserialize");
assert_eq!(back.new_file_template, cfg.new_file_template);
}
/// A config.json written before **File ▸ New project…** existed. Same risk
/// as above: a `#[serde(default)]` missing from any new field would make the
/// whole file fail to parse and reset the user's settings.
#[test]
fn config_without_the_project_fields_still_loads() {
let old = r####"{
"workspace": "/home/writer/Manuscript",
"export_path": "/home/writer/Manuscript/book.odt",
"draft_marker": "### Rough Draft:",
"mistral_api_key": "secret"
}"####;
let cfg: Config = serde_json::from_str(old).expect("old config must still parse");
assert_eq!(cfg.workspace, PathBuf::from("/home/writer/Manuscript"));
assert_eq!(cfg.mistral_api_key, "secret");
// Every new field arrives at its default rather than blank.
assert_eq!(cfg.project_open_subdir, default_project_open_subdir());
assert_eq!(cfg.project_template, default_project_template());
assert_eq!(cfg.projects_dir, default_projects_dir());
assert!(cfg.project_run_hooks, "hooks default to running");
// Credentials are absent until set; blank is what tells a hook to skip.
assert!(cfg.gitea_url.is_empty());
assert!(cfg.gitea_token.is_empty());
assert!(cfg.cookiecutter_bin.is_empty());
}
/// The folder opened after generating a project. It is the snowflake
/// template's drafting folder, and the name contains a space — which has to
/// survive being stored and joined onto the project path.
#[test]
fn the_default_project_subdir_is_the_drafting_folder() {
assert_eq!(default_project_open_subdir(), "06-First Draft");
let joined = PathBuf::from("/tmp/The Winter Gate").join(default_project_open_subdir());
assert_eq!(joined, PathBuf::from("/tmp/The Winter Gate/06-First Draft"));
}
/// A saved subfolder choice must win over the built-in default, so changing
/// the default never silently moves an existing user's projects.
#[test]
fn a_saved_project_subdir_is_honoured() {
let saved = r####"{
"workspace": "/w",
"export_path": "/w/b.odt",
"project_open_subdir": "05-Plot"
}"####;
let cfg: Config = serde_json::from_str(saved).expect("must parse");
assert_eq!(cfg.project_open_subdir, "05-Plot");
}
}