Bundle the project template into the binary; add outline-to-draft

links, a sprint timer, placeholder scanning, corkboard, and a
quick-capture inbox

- Compile templates/snowflake into the binary via include_dir, so New
  project… never depends on files installed beside the executable;
  Settings ▸ Template becomes an optional external override, and a
  broken saved override now falls back to the bundled template
  instead of failing project generation.
- Outline beats now link to a matching chapter for a one-click jump.
- Sprint window: a daily whole-manuscript word goal plus a
  Pomodoro-style writing sprint timer, with a compact countdown in the
  status bar while in Distraction-free mode.
- Placeholders window: find [TODO]/[TK]/[FIXME]/[XXX]/[NOTE]/[???]
  markers left in the draft and jump straight to any of them.
- Snowflake progress strip in the file panel: a nudge-only readout of
  which snowflake stage folders actually have content.
- Corkboard window: drag-reorderable synopsis cards, one per chapter.
- Quick-capture popup (Ctrl+Shift+N): jot a stray idea straight into
  the project's Scratch Pad without losing your place in the draft.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-30 11:38:31 -05:00
parent 6ca8681d08
commit 7854a961a9
19 changed files with 1256 additions and 163 deletions
+128 -16
View File
@@ -3,12 +3,81 @@
//! This module replaces the cookiecutter dependency by providing native Rust
//! templating for project generation. It uses MiniJinja which is Jinja2-compatible,
//! so existing cookiecutter templates (like snowflake) work with minimal changes.
//!
//! The default (bundled) template's files are compiled into the binary via
//! [`BUNDLED_SNOWFLAKE`], so **File ▸ New project…** works out of the box —
//! no `templates/` folder has to be installed beside the executable. A writer
//! can still point at a folder of their own (`Settings ▸ New project… ▸
//! Template`) to use a customized copy instead; see [`TemplateSource`].
use include_dir::{include_dir, Dir};
use minijinja::Environment;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::fs;
/// The bundled "snowflake" project template, compiled into the binary so
/// **File ▸ New project…** never depends on a `templates/` folder installed
/// beside the executable.
static BUNDLED_SNOWFLAKE: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/templates/snowflake");
/// Where a project template comes from: the one compiled into the binary, or
/// a folder on disk the writer has pointed **Settings ▸ New project… ▸
/// Template** at (to use a customized copy of their own).
#[derive(Debug, Clone)]
pub enum TemplateSource {
Bundled,
External(PathBuf),
}
impl TemplateSource {
/// Whether this source is actually usable. `Bundled` always is — it's
/// compiled in; `External` needs a real `cookiecutter.json` at the path.
pub fn is_available(&self) -> bool {
match self {
TemplateSource::Bundled => true,
TemplateSource::External(path) => path.join("cookiecutter.json").is_file(),
}
}
}
/// Removes its directory on drop, so the bundled template's temp extraction
/// is cleaned up whether `render_template` returns `Ok` or `Err`.
struct TempExtraction {
dir: PathBuf,
}
impl Drop for TempExtraction {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.dir);
}
}
/// Write the bundled template out to a fresh temp directory, so the rest of
/// the render pipeline can treat it exactly like an on-disk template.
fn extract_bundled() -> Result<(PathBuf, TempExtraction), String> {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let dir = std::env::temp_dir().join(format!(
"md-manuscript-template-{}-{unique}",
std::process::id()
));
// `Dir::extract` fails if any file already exists, so clear any stale
// leftovers (e.g. from a previous crash) first.
let _ = fs::remove_dir_all(&dir);
// `extract` creates *sub*directories as it walks in, but never the root
// path itself — with top-level files in the template (cookiecutter.json,
// README.md), it would otherwise try to write into a directory that
// doesn't exist yet.
fs::create_dir_all(&dir).map_err(|e| format!("Cannot create {}: {e}", dir.display()))?;
BUNDLED_SNOWFLAKE
.extract(&dir)
.map_err(|e| format!("Failed to extract the bundled template: {e}"))?;
Ok((dir.clone(), TempExtraction { dir }))
}
/// Template variables passed to MiniJinja.
#[derive(Debug, Clone)]
pub struct TemplateVariables {
@@ -55,8 +124,9 @@ impl TemplateVariables {
/// Configuration for template rendering.
pub struct RenderConfig {
/// Path to the template directory (contains cookiecutter.json and template folder)
pub template_dir: PathBuf,
/// Where the template (with its cookiecutter.json and template folder)
/// comes from.
pub template: TemplateSource,
/// Output directory where the project will be created
pub output_dir: PathBuf,
/// Template variables
@@ -77,12 +147,24 @@ pub type RenderResult = Result<PathBuf, String>;
/// a template expression (e.g., `{{ cookiecutter.project_name }}`) which
/// becomes the project root after rendering.
pub fn render_template(config: &RenderConfig) -> RenderResult {
// Resolve the template to a real directory on disk: an external override
// is used as-is, while the bundled template is extracted to a fresh temp
// directory first. `_guard` cleans that temp directory up when this
// function returns, however it returns.
let (template_dir, _guard): (PathBuf, Option<TempExtraction>) = match &config.template {
TemplateSource::External(path) => (path.clone(), None),
TemplateSource::Bundled => {
let (dir, guard) = extract_bundled()?;
(dir, Some(guard))
}
};
// Verify the template directory has a cookiecutter.json
let cookiecutter_json = config.template_dir.join("cookiecutter.json");
let cookiecutter_json = template_dir.join("cookiecutter.json");
if !cookiecutter_json.is_file() {
return Err(format!(
"No cookiecutter.json in {} — that folder is not a template.",
config.template_dir.display()
template_dir.display()
));
}
@@ -130,8 +212,8 @@ pub fn render_template(config: &RenderConfig) -> RenderResult {
// Find the template project directory (the one with the templated name)
// This is typically named something like "{{ cookiecutter.project_name }}"
let template_entries = fs::read_dir(&config.template_dir)
.map_err(|e| format!("Cannot read template directory {}: {}", config.template_dir.display(), e))?;
let template_entries = fs::read_dir(&template_dir)
.map_err(|e| format!("Cannot read template directory {}: {}", template_dir.display(), e))?;
let mut template_project_dir: Option<PathBuf> = None;
for entry in template_entries {
@@ -149,7 +231,7 @@ pub fn render_template(config: &RenderConfig) -> RenderResult {
let template_project_dir = template_project_dir.ok_or(format!(
"No template project directory found in {}. Expected a directory with a templated name like '{{{{ cookiecutter.project_name }}}}'.",
config.template_dir.display()
template_dir.display()
))?;
// Render the project directory name
@@ -307,22 +389,19 @@ mod tests {
assert_eq!(name, "test project");
}
/// Integration test: render the snowflake template using MiniJinja.
/// This tests that the templating works end-to-end with a real template.
/// Integration test: render the bundled snowflake template using
/// MiniJinja. This tests that the templating works end-to-end, and that
/// the compiled-in template is actually extracted and used correctly —
/// there's no "skip if the template isn't installed" escape hatch
/// anymore, since it's always available now.
#[test]
fn test_render_snowflake_template() {
let template_dir = crate::config::default_project_template();
if !template_dir.join("cookiecutter.json").is_file() {
eprintln!("skipping: no snowflake template at {}", template_dir.display());
return;
}
let out = std::env::temp_dir().join("md_manuscript_minijinja_test");
let _ = std::fs::remove_dir_all(&out);
std::fs::create_dir_all(&out).unwrap();
let config = RenderConfig {
template_dir,
template: TemplateSource::Bundled,
output_dir: out.clone(),
variables: TemplateVariables::new(
"MiniJinja Test Project",
@@ -369,4 +448,37 @@ mod tests {
// Clean up
let _ = std::fs::remove_dir_all(&out);
}
/// The embed itself has to actually hold the template — a hermetic check
/// (no filesystem, no temp directory) that what's compiled into the
/// binary is a real, usable snowflake template.
#[test]
fn bundled_template_contains_the_expected_files() {
assert!(
BUNDLED_SNOWFLAKE.get_file("cookiecutter.json").is_some(),
"bundled template is missing cookiecutter.json"
);
let project_dir = BUNDLED_SNOWFLAKE
.dirs()
.find(|d| {
d.path()
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.contains("{{"))
})
.expect("bundled template is missing its `{{ cookiecutter.project_name }}` folder");
let subdir = crate::config::default_project_open_subdir();
assert!(
project_dir.get_dir(project_dir.path().join(&subdir)).is_some(),
"bundled template is missing its {subdir} folder"
);
}
#[test]
fn extracting_the_bundled_template_cleans_up_after_itself() {
let (dir, guard) = extract_bundled().expect("extraction should succeed");
assert!(dir.join("cookiecutter.json").is_file());
drop(guard);
assert!(!dir.exists(), "temp extraction should be removed on drop");
}
}