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
+107 -14
View File
@@ -3,16 +3,20 @@
A small desktop application for Debian that treats a directory of markdown files A small desktop application for Debian that treats a directory of markdown files
as an ordered manuscript. as an ordered manuscript.
* **Left pane** — every `*.md` file in the workspace directory, in a manual order * **Left pane** — every `*.md` file in the workspace, as a collapsible folder
you set by **dragging the `⠿` handle** up and down. **Hover a file** to see a tree, in a manual order you set by **dragging the `⠿` handle** up and down.
tooltip built from its header — the `POV:` line and the `# Slug:` synopsis — Drag a file onto a folder to move it there. **Hover a file** to see a tooltip
a quick at-a-glance summary of each chapter. built from its header — the `POV:` line and the `# Slug:` synopsis — a quick
at-a-glance summary of each chapter.
* **Right pane** — a markdown text editor for the selected file (optional live * **Right pane** — a markdown text editor for the selected file (optional live
preview via the *Preview* checkbox). preview via the *Preview* checkbox).
* **Order + contents are saved and version-controlled with git.** The order lives * **Order + contents are saved and version-controlled with git.** The order lives
in `order.json` inside the workspace, so it syncs along with the files. in `order.json` inside the workspace, so it syncs along with the files. It
records each file's path, so subfolders travel with it.
* **Export** the whole manuscript as a single concatenated `.odt` file, with a * **Export** the whole manuscript as a single concatenated `.odt` file, with a
chapter heading before each file's content. chapter heading before each file's content.
* **Start a new project** from a cookiecutter template (**File ▸ New project…**),
which builds the whole folder structure and opens its drafting folder.
Built in Rust with [`egui`](https://github.com/emilk/egui)/`eframe`. The ODT Built in Rust with [`egui`](https://github.com/emilk/egui)/`eframe`. The ODT
writer is native (no `pandoc`/LibreOffice needed at runtime). writer is native (no `pandoc`/LibreOffice needed at runtime).
@@ -71,10 +75,12 @@ Terminal=false
enclosing repository**. Accept it to have **⟳ Sync** commit and push to that enclosing repository**. Accept it to have **⟳ Sync** commit and push to that
repo; decline to leave the workspace on its own, where **Init git** creates a repo; decline to leave the workspace on its own, where **Init git** creates a
separate repository in the folder. separate repository in the folder.
3. Create files with ** New** (type a name first) or ** New from template** 3. Create files with ** New** (type a name first — give it a path like
`part-1/ch-01` to nest it, see [Organising with folders](#organising-with-folders))
or ** New from template**
(no name needed — see [New files from a template](#new-files-from-a-template)), (no name needed — see [New files from a template](#new-files-from-a-template)),
edit on the right, `Ctrl+S` (or the Save button) edit on the right, `Ctrl+S` (or the Save button)
to write to disk. Drag the `` handles to reorder. Use the **Zoom** slider to write to disk. Drag the `` handles to reorder or to move between folders. Use the **Zoom** slider
above the editor to scale the editor text (0% = default; **Reset** returns to above the editor to scale the editor text (0% = default; **Reset** returns to
it), and the **Contrast** slider to brighten dim editor text (0% = theme it), and the **Contrast** slider to brighten dim editor text (0% = theme
default; higher pushes the text toward white in dark mode / black in light default; higher pushes the text toward white in dark mode / black in light
@@ -139,18 +145,105 @@ A single number (`## Word Count Target: 1800`) sets a point goal; ranges accept
`-`, ``, `to`, and grouped digits (`1,500`). Like the other header lines, the `-`, ``, `to`, and grouped digits (`1,500`). Like the other header lines, the
target is stripped from the exported document. target is stripped from the exported document.
## Organising with folders
The file list is a tree. Any `*.md` file at or below the workspace is part of the
manuscript, so you can group a long book into `part-1/`, `part-2/`, or go further
and give a chapter its own folder of scenes — nesting goes eight levels deep.
* **Make a folder** by giving a new file a path: type `part-1/ch-01` beside
** New** and both the folder and the file appear. There are no standalone
empty folders — a folder exists for as long as it holds a file, and is cleaned
up automatically once its last file leaves.
* **Collapse or expand** a folder by clicking its header. The number beside the
name counts every file beneath it, at any depth.
* **Move a file** by dragging its `` handle onto a folder header, or in among
another folder's files. The file really moves on disk, taking its chapter-title
override with it. Drop it on **↥ drop here for the top level** to bring it back
out of every folder.
* **Move by typing** instead, if you prefer: the **Rename** box holds the file's
whole path within the workspace, so editing the folder part of it moves the
file just as dragging does.
* ** New from template** puts its `untitled-N.md` in the folder of the file you
are editing, so working inside a part doesn't scatter new files at the top.
Folders are skipped when they cannot hold manuscript prose: anything starting
with a `.` (so `.git` is never walked), plus `target/` and `node_modules/`.
### Folders and the manuscript order
Order is still yours to set by dragging, and still lives in `order.json` — as
paths (`part-1/ch-03.md`) rather than bare names. The one rule the app enforces
is that **a folder's files stay together**: a folder sits wherever its
earliest-ordered file puts it, and everything under it follows in a block.
That is what keeps the panel honest — **the export is the tree read top to
bottom**, so the order you see down the left is the order the chapters are
concatenated in, folders and all. Chapter numbering follows the same sequence,
so a defaulted title in `part-2/` continues counting from `part-1/`.
## New projects from a cookiecutter template
**File ▸ ✨ New project…** scaffolds a whole manuscript project from a
[cookiecutter](https://cookiecutter.readthedocs.io/) template and then opens its
drafting folder as the workspace, so you go from nothing to editing chapters in
one step.
The dialog asks for a **name**, **author**, **description** and the **folder to
create it in** (prefilled with wherever you put the last one). Those are passed
to the template as the `project_name`, `author` and `description` variables.
Nothing is written until you press **Create**; generation runs in the background,
so a template whose hooks talk to the network doesn't freeze the editor.
When it finishes, the app opens the project's **`06-First Draft`** subfolder as
the workspace — that is where the snowflake template keeps the chapter files, in
`Act 1/`, `Act 2/` and `Act 3/` subfolders that the
[folder tree](#organising-with-folders) shows directly. If the template didn't
produce that folder, the project root is opened instead and the status bar says
so.
A folder only appears in the file panel once it holds a `.md` file — the
template's empty acts are held open with `.gitkeep`, which is not markdown, so
they show up as you add scenes to them.
### Settings ▸ New project…
| Setting | Meaning |
|---|---|
| **Template** | the folder holding the template's `cookiecutter.json`. Defaults to `~/Documents/Cookiecutters/snowflake`; the dialog confirms whether a `cookiecutter.json` is actually there |
| **Open subfolder** | which folder of the generated project becomes the workspace (default `06-First Draft`; blank opens the project root) |
| **cookiecutter path** | leave blank to search `PATH` and then the usual per-user Python prefixes (`~/.anaconda3/bin`, `~/miniconda3/bin`, `~/.local/bin`, …), which is what a desktop launcher needs since it does not inherit a conda `PATH`. The dialog shows which executable was found |
| **Run the template's hooks** | whether to run the template's pre/post-generation scripts (on by default) |
| **Gitea** | server URL, user and token, handed to the hooks as `GITEA_URL` / `GITEA_USER` / `GITEA_TOKEN` |
Cookiecutter is a Python program and is **not bundled** — install it with
`pip install cookiecutter` or `conda install cookiecutter` if the settings window
reports it missing.
### Hooks and publishing
The snowflake template's `hooks/post_gen_project.py` creates a repository on a
Gitea server and pushes the new project to it. It only does that when all three
Gitea settings are filled in; with any of them blank it prints a note and skips,
and **the project is still generated either way**. The token is passed to the
push command alone and is not written into the project's `.git/config`.
The token is stored in `config.json` in plain text, the same as the Mistral API
key.
## New files from a template ## New files from a template
The file list has two create buttons: The file list has two create buttons:
| Button | Name | Contents | | Button | Name | Contents |
|---|---|---| |---|---|---|
| ** New** | the name you type beside it | `# <name>` and a blank line | | ** New** | the name you type beside it, optionally with folders | `# <name>` and a blank line |
| ** New from template** | auto-assigned `untitled-N.md` | the configured template | | ** New from template** | auto-assigned `untitled-N.md` | the configured template |
** New from template** takes no typed name — it picks the first free ** New from template** takes no typed name — it picks the first free
`untitled-N.md` (reusing a gap if you have deleted one), seeds it from the `untitled-N.md` (reusing a gap if you have deleted one) in the folder of the file
template, appends it to the manuscript order and selects it. Rename it later with you are currently editing, seeds it from the template, adds it to the manuscript
order and selects it. Rename it later with
the **Rename** box, or set a `# Title:` header and let the chapter title come the **Rename** box, or set a `# Title:` header and let the chapter title come
from that. from that.
@@ -328,10 +421,10 @@ only triggers in the header region, never in the prose below the marker.
| Location | Purpose | | Location | Purpose |
|---|---| |---|---|
| `<workspace>/*.md` | your manuscript files | | `<workspace>/**/*.md` | your manuscript files, in folders if you like |
| `<workspace>/order.json` | the manual ordering (committed to git) | | `<workspace>/order.json` | the manual ordering, as paths (committed to git) |
| `<workspace>/titles.json` | chapter-title overrides (committed to git) | | `<workspace>/titles.json` | chapter-title overrides, keyed by path (committed to git) |
| `~/.config/md-manuscript/config.json` | last workspace, export path, prefs, new-file template | | `~/.config/md-manuscript/config.json` | last workspace, export path, prefs, new-file template, project/Gitea settings |
## Markdown supported in ODT export ## Markdown supported in ODT export
+1
View File
@@ -102,6 +102,7 @@ impl App {
Ok(_) => { Ok(_) => {
if !self.files.contains(&name) { if !self.files.contains(&name) {
self.files.push(name.clone()); self.files.push(name.clone());
self.files = order::tree_order(&self.files);
self.persist_order(); self.persist_order();
} }
if let Some(idx) = self.files.iter().position(|f| f == &name) { if let Some(idx) = self.files.iter().position(|f| f == &name) {
+368 -24
View File
@@ -1,8 +1,108 @@
//! The left-hand file list: drag-to-reorder rows, chapter titles and the //! The left-hand file list: a collapsible folder tree of the workspace's
//! per-file word-count progress bar. //! markdown files, drag-to-reorder (and drag-to-move) rows, chapter titles and
//! the per-file word-count progress bar.
use super::*; use super::*;
/// Horizontal indent per folder level, in points.
const INDENT: f32 = 12.0;
/// One drawn line of the file tree.
pub(super) struct Row {
/// Workspace-relative path: the folder for a folder row, the file for a
/// file row.
pub path: String,
/// Nesting level; 0 sits directly in the workspace root.
pub depth: usize,
pub kind: RowKind,
}
pub(super) enum RowKind {
/// A folder header: how many markdown files live under it at any depth, and
/// the flat index of the first of them (where a drop into the folder lands).
Folder { count: usize, first: usize },
/// A file, by its index into [`App::files`].
File { idx: usize },
}
/// Where a dragged file was let go: which file moved, the flat position it
/// should take, and the folder it should end up in (`""` = the workspace root).
pub(super) struct FileDrop {
pub from: usize,
pub to: usize,
pub dir: String,
}
/// Whether a collapsed folder somewhere above `path` is hiding it. The last
/// component of `path` is the item itself, so it is never its own concealer.
fn hidden_by_collapse(path: &str, collapsed: &HashSet<String>) -> bool {
let mut ancestor = String::new();
let mut parts = path.split('/').peekable();
while let Some(part) = parts.next() {
if parts.peek().is_none() {
return false;
}
if !ancestor.is_empty() {
ancestor.push('/');
}
ancestor.push_str(part);
if collapsed.contains(&ancestor) {
return true;
}
}
false
}
/// Turn the flat, folder-tree-ordered file list into the rows to draw, opening
/// a folder header wherever the path prefix changes and skipping everything
/// inside a collapsed folder.
///
/// This relies on `files` being in [`crate::order::tree_order`]: because each
/// folder's files are contiguous there, a header is needed exactly once, and
/// the file that opens it is by construction the folder's first.
pub(super) fn build_rows(files: &[String], collapsed: &HashSet<String>) -> Vec<Row> {
let mut rows = Vec::new();
// The folder components currently open, outermost first.
let mut open: Vec<&str> = Vec::new();
for (idx, path) in files.iter().enumerate() {
let mut parts: Vec<&str> = path.split('/').collect();
parts.pop(); // the file name itself
let shared = open
.iter()
.zip(&parts)
.take_while(|(a, b)| a == b)
.count();
open.truncate(shared);
for dir in &parts[shared..] {
open.push(dir);
let folder = open.join("/");
if hidden_by_collapse(&folder, collapsed) {
continue;
}
// Contiguity makes the run of files under this folder easy to count.
let prefix = format!("{folder}/");
let count = files[idx..]
.iter()
.take_while(|f| f.starts_with(&prefix))
.count();
rows.push(Row {
depth: open.len() - 1,
kind: RowKind::Folder { count, first: idx },
path: folder,
});
}
if !hidden_by_collapse(path, collapsed) {
rows.push(Row {
path: path.clone(),
depth: parts.len(),
kind: RowKind::File { idx },
});
}
}
rows
}
impl App { impl App {
pub(super) fn left_pane(&mut self, ctx: &egui::Context) { pub(super) fn left_pane(&mut self, ctx: &egui::Context) {
egui::SidePanel::left("files") egui::SidePanel::left("files")
@@ -16,51 +116,118 @@ impl App {
ui.add_space(4.0); ui.add_space(4.0);
ui.heading("Files"); ui.heading("Files");
ui.label( ui.label(
egui::RichText::new("drag ⠿ to reorder") egui::RichText::new("drag ⠿ to reorder · drop on a folder to move")
.small() .small()
.weak(), .weak(),
); );
ui.separator(); ui.separator();
let mut clicked: Option<usize> = None; let mut clicked: Option<usize> = None;
let mut from_to: Option<(usize, usize)> = None; let mut toggled: Option<String> = None;
let mut dropped: Option<FileDrop> = None;
let pointer = ui.input(|i| i.pointer.interact_pos()); let pointer = ui.input(|i| i.pointer.interact_pos());
let rows = build_rows(&self.files, &self.collapsed);
let nested = rows.iter().any(|r| r.depth > 0);
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.auto_shrink([false, false]) .auto_shrink([false, false])
.max_height(ui.available_height() - 120.0) .max_height(ui.available_height() - 120.0)
.show(ui, |ui| { .show(ui, |ui| {
for idx in 0..self.files.len() { for row in &rows {
let name = self.files[idx].clone(); let indent = row.depth as f32 * INDENT;
match row.kind {
RowKind::Folder { count, first } => {
let open = !self.collapsed.contains(&row.path);
let header = ui
.horizontal(|ui| {
ui.add_space(indent);
let arrow = if open { "" } else { "" };
let label = format!(
"{arrow} 🗀 {}",
base_name(&row.path)
);
if ui
.add(
egui::Button::new(
egui::RichText::new(label).strong(),
)
.frame(false),
)
.on_hover_text(&row.path)
.clicked()
{
toggled = Some(row.path.clone());
}
ui.label(
egui::RichText::new(count.to_string())
.small()
.weak(),
);
// Claim the rest of the line so the
// whole row is a drop target.
ui.allocate_space(egui::vec2(
ui.available_width(),
0.0,
));
})
.response;
if drop_highlight(ui, &header) {
if let Some(payload) =
header.dnd_release_payload::<usize>()
{
dropped = Some(FileDrop {
from: *payload,
to: first,
dir: row.path.clone(),
});
}
}
}
RowKind::File { idx } => {
let name = row.path.clone();
let selected = self.selected == Some(idx); let selected = self.selected == Some(idx);
// The selected file's fields are read live from the // The selected file's fields are read live from
// buffer (so unsaved edits show); others come from the // the buffer (so unsaved edits show); others come
// cache filled on open/save. // from the cache filled on open/save.
let meta = if selected { let meta = if selected {
FileMeta::from_markdown(&self.buffer, &self.config.draft_marker) FileMeta::from_markdown(
&self.buffer,
&self.config.draft_marker,
)
} else { } else {
self.file_meta.get(&name).cloned().unwrap_or_default() self.file_meta.get(&name).cloned().unwrap_or_default()
}; };
let tooltip = meta.tooltip(); let tooltip = meta.tooltip();
let row = ui let file_row = ui
.horizontal(|ui| { .horizontal(|ui| {
ui.add_space(indent);
ui.dnd_drag_source( ui.dnd_drag_source(
egui::Id::new(("dnd", &name)), egui::Id::new(("dnd", &name)),
idx, idx,
|ui| { |ui| {
ui.label( ui.label(
egui::RichText::new("").monospace().weak(), egui::RichText::new("")
.monospace()
.weak(),
); );
}, },
); );
// Reserve room on the right for a per-file // Reserve room on the right for a per-file
// word-count-target bar when the file sets one. // word-count-target bar when the file sets one.
let bar_w = 44.0; let bar_w = 44.0;
let reserve = if meta.goal.is_some() { bar_w + 6.0 } else { 0.0 }; let reserve = if meta.goal.is_some() {
let label_w = (ui.available_width() - reserve).max(24.0); bar_w + 6.0
} else {
0.0
};
let label_w =
(ui.available_width() - reserve).max(24.0);
let mut label = ui.add_sized( let mut label = ui.add_sized(
[label_w, 20.0], [label_w, 20.0],
egui::SelectableLabel::new(selected, &name), egui::SelectableLabel::new(
selected,
base_name(&name),
),
); );
if let Some(tooltip) = &tooltip { if let Some(tooltip) = &tooltip {
label = label.on_hover_text(tooltip); label = label.on_hover_text(tooltip);
@@ -69,14 +236,19 @@ impl App {
clicked = Some(idx); clicked = Some(idx);
} }
if let Some(goal) = meta.goal { if let Some(goal) = meta.goal {
row_goal_bar(ui, bar_w, goal, meta.prose_words); row_goal_bar(
ui,
bar_w,
goal,
meta.prose_words,
);
} }
}) })
.response; .response;
// Drop handling: is a dragged item hovering this row? // Drop handling: is a dragged item hovering this row?
if let Some(_payload) = row.dnd_hover_payload::<usize>() { if file_row.dnd_hover_payload::<usize>().is_some() {
let rect = row.rect; let rect = file_row.rect;
let before = pointer let before = pointer
.map(|p| p.y < rect.center().y) .map(|p| p.y < rect.center().y)
.unwrap_or(true); .unwrap_or(true);
@@ -93,9 +265,43 @@ impl App {
ui.visuals().selection.stroke.color, ui.visuals().selection.stroke.color,
), ),
); );
if let Some(payload) = row.dnd_release_payload::<usize>() { if let Some(payload) =
let target = if before { idx } else { idx + 1 }; file_row.dnd_release_payload::<usize>()
from_to = Some((*payload, target)); {
dropped = Some(FileDrop {
from: *payload,
to: if before { idx } else { idx + 1 },
// Dropping among a folder's files
// means joining that folder.
dir: parent_dir(&name).to_string(),
});
}
}
}
}
}
// With folders in play there is otherwise no row to aim at
// to get a file back out to the top level.
if nested {
ui.add_space(2.0);
let target = ui
.horizontal(|ui| {
ui.label(
egui::RichText::new("↥ drop here for the top level")
.small()
.weak(),
);
ui.allocate_space(egui::vec2(ui.available_width(), 0.0));
})
.response;
if drop_highlight(ui, &target) {
if let Some(payload) = target.dnd_release_payload::<usize>() {
dropped = Some(FileDrop {
from: *payload,
to: self.files.len(),
dir: String::new(),
});
} }
} }
} }
@@ -104,8 +310,13 @@ impl App {
if let Some(idx) = clicked { if let Some(idx) = clicked {
self.select(idx); self.select(idx);
} }
if let Some((from, to)) = from_to { if let Some(path) = toggled {
self.reorder(from, to); if !self.collapsed.remove(&path) {
self.collapsed.insert(path);
}
}
if let Some(drop) = dropped {
self.apply_drop(drop);
} }
ui.separator(); ui.separator();
@@ -114,6 +325,10 @@ impl App {
egui::TextEdit::singleline(&mut self.new_name) egui::TextEdit::singleline(&mut self.new_name)
.hint_text("new file name") .hint_text("new file name")
.desired_width(150.0), .desired_width(150.0),
)
.on_hover_text(
"A name, or a path to nest it: part-1/ch-01 creates the \
folder along with the file.",
); );
if ui.button(" New").clicked() { if ui.button(" New").clicked() {
self.create_file(); self.create_file();
@@ -123,7 +338,7 @@ impl App {
.button(" New from template") .button(" New from template")
.on_hover_text( .on_hover_text(
"Create untitled-N.md seeded from the template \ "Create untitled-N.md seeded from the template \
(Settings ▸ New-file template…)", (Settings ▸ New-file template…), in the current file's folder",
) )
.clicked() .clicked()
{ {
@@ -136,6 +351,10 @@ impl App {
egui::TextEdit::singleline(&mut self.rename_input) egui::TextEdit::singleline(&mut self.rename_input)
.hint_text("rename") .hint_text("rename")
.desired_width(150.0), .desired_width(150.0),
)
.on_hover_text(
"The file's path within the workspace — edit the folder \
part to move it.",
); );
if ui.button("Rename").clicked() { if ui.button("Rename").clicked() {
self.rename_selected(); self.rename_selected();
@@ -164,6 +383,20 @@ impl App {
} }
} }
/// Outline a whole-row drop target while a file is dragged over it, and report
/// whether it is being hovered (so the caller can look for the release).
fn drop_highlight(ui: &egui::Ui, response: &egui::Response) -> bool {
if response.dnd_hover_payload::<usize>().is_none() {
return false;
}
ui.painter().rect_stroke(
response.rect,
egui::Rounding::same(2.0),
egui::Stroke::new(2.0_f32, ui.visuals().selection.stroke.color),
);
true
}
/// Resolve a chapter's title: a non-empty manual `override_title` wins, then the /// Resolve a chapter's title: a non-empty manual `override_title` wins, then the
/// `# Title:` header value, otherwise the chapter's 1-based position followed by /// `# Title:` header value, otherwise the chapter's 1-based position followed by
/// a period (e.g. "3."), zero-padded to `pad_width` digits (`1` = no padding). /// a period (e.g. "3."), zero-padded to `pad_width` digits (`1` = no padding).
@@ -267,4 +500,115 @@ mod tests {
"The Gate" "The Gate"
); );
} }
/// Render the rows as `depth:kind:path` lines, which is compact enough to
/// assert the whole tree shape in one go.
fn sketch(files: &[&str], collapsed: &[&str]) -> Vec<String> {
let files: Vec<String> = files.iter().map(|s| s.to_string()).collect();
let collapsed: HashSet<String> = collapsed.iter().map(|s| s.to_string()).collect();
build_rows(&files, &collapsed)
.iter()
.map(|r| match r.kind {
RowKind::Folder { count, first } => {
format!("{}:dir({count},{first}):{}", r.depth, r.path)
}
RowKind::File { idx } => format!("{}:file({idx}):{}", r.depth, r.path),
})
.collect()
}
#[test]
fn flat_files_get_no_folder_rows() {
assert_eq!(
sketch(&["a.md", "b.md"], &[]),
vec!["0:file(0):a.md", "0:file(1):b.md"]
);
}
#[test]
fn a_folder_header_is_opened_once_for_its_run_of_files() {
assert_eq!(
sketch(&["p/a.md", "p/b.md", "top.md"], &[]),
vec![
"0:dir(2,0):p",
"1:file(0):p/a.md",
"1:file(1):p/b.md",
"0:file(2):top.md",
]
);
}
#[test]
fn nested_folders_indent_and_count_everything_below_them() {
assert_eq!(
sketch(&["p/q/a.md", "p/b.md"], &[]),
vec![
// `p` counts both files; `q` only its own.
"0:dir(2,0):p",
"1:dir(1,0):p/q",
"2:file(0):p/q/a.md",
"1:file(1):p/b.md",
]
);
}
#[test]
fn collapsing_a_folder_hides_its_files_but_keeps_its_header() {
assert_eq!(
sketch(&["p/a.md", "p/b.md", "top.md"], &["p"]),
vec!["0:dir(2,0):p", "0:file(2):top.md"]
);
}
#[test]
fn collapsing_hides_nested_headers_too() {
assert_eq!(
sketch(&["p/q/a.md", "p/b.md", "top.md"], &["p"]),
vec!["0:dir(2,0):p", "0:file(2):top.md"]
);
// Collapsing only the inner folder leaves the outer one drawn.
assert_eq!(
sketch(&["p/q/a.md", "p/b.md"], &["p/q"]),
vec!["0:dir(2,0):p", "1:dir(1,0):p/q", "1:file(1):p/b.md"]
);
}
#[test]
fn a_collapsed_name_only_hides_its_own_children() {
// `part-1` must not swallow `part-10`, which merely shares a prefix.
assert_eq!(
sketch(&["part-1/a.md", "part-10/b.md"], &["part-1"]),
vec![
"0:dir(1,0):part-1",
"0:dir(1,1):part-10",
"1:file(1):part-10/b.md",
]
);
}
#[test]
fn sibling_folders_each_get_their_own_header() {
assert_eq!(
sketch(&["p/a.md", "q/b.md"], &[]),
vec![
"0:dir(1,0):p",
"1:file(0):p/a.md",
"0:dir(1,1):q",
"1:file(1):q/b.md",
]
);
}
/// The first index a folder header reports is where a drop into that folder
/// lands, so it has to point at the folder's own first file.
#[test]
fn a_folder_header_points_at_its_first_file() {
let rows = sketch(&["top.md", "p/a.md", "p/b.md"], &[]);
assert_eq!(rows[1], "0:dir(2,1):p");
}
#[test]
fn an_empty_list_draws_nothing() {
assert!(sketch(&[], &[]).is_empty());
}
} }
+42 -2
View File
@@ -9,7 +9,7 @@ use crate::gitsync;
use crate::odt::{self, Chapter}; use crate::odt::{self, Chapter};
use crate::order; use crate::order;
use eframe::egui; use eframe::egui;
use std::collections::HashMap; use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@@ -19,6 +19,7 @@ mod beats;
mod editor; mod editor;
mod file_list; mod file_list;
mod find; mod find;
mod project;
mod grammar; mod grammar;
mod spelling; mod spelling;
mod style; mod style;
@@ -31,6 +32,7 @@ mod workspace;
use self::autocomplete::*; use self::autocomplete::*;
use self::file_list::*; use self::file_list::*;
use self::grammar::*; use self::grammar::*;
use self::project::*;
use self::style::*; use self::style::*;
use self::util::*; use self::util::*;
@@ -161,8 +163,14 @@ struct IssueItem {
pub struct App { pub struct App {
config: Config, config: Config,
/// Ordered markdown file names (relative to the workspace). /// Ordered markdown files, as workspace-relative paths with `/` separators
/// (`part-1/ch-03.md`). Always held in folder-tree order — see
/// [`crate::order::tree_order`] — so this list reads exactly as the file
/// panel draws it and as the export concatenates it.
files: Vec<String>, files: Vec<String>,
/// Folders the user has collapsed in the file panel, as workspace-relative
/// paths. Purely a view concern, so it is not persisted.
collapsed: HashSet<String>,
/// Per-file chapter title overrides (file name -> title). Missing/empty means /// Per-file chapter title overrides (file name -> title). Missing/empty means
/// the title is derived automatically at export time. /// the title is derived automatically at export time.
titles: HashMap<String, String>, titles: HashMap<String, String>,
@@ -276,6 +284,20 @@ pub struct App {
beats_output: Option<String>, beats_output: Option<String>,
/// File stem of the proposal the beats came from, for the default save name. /// File stem of the proposal the beats came from, for the default save name.
beats_source_stem: Option<String>, beats_source_stem: Option<String>,
/// Whether the new-project dialog is open.
show_new_project: bool,
/// New-project dialog fields.
np_name: String,
np_author: String,
np_description: String,
/// Directory the project will be created in.
np_parent: String,
/// One-line status for the new-project dialog (validation or failure).
np_status: String,
/// In-flight background project generation, if any.
np_rx: Option<std::sync::mpsc::Receiver<ProjectMsg>>,
/// Whether the new-project settings window is open.
show_project_settings: bool,
} }
impl App { impl App {
@@ -287,6 +309,7 @@ impl App {
export_input: config.export_path.display().to_string(), export_input: config.export_path.display().to_string(),
config, config,
files: Vec::new(), files: Vec::new(),
collapsed: HashSet::new(),
titles: HashMap::new(), titles: HashMap::new(),
title_input: String::new(), title_input: String::new(),
selected: None, selected: None,
@@ -340,6 +363,14 @@ impl App {
beats_status: String::new(), beats_status: String::new(),
beats_output: None, beats_output: None,
beats_source_stem: None, beats_source_stem: None,
show_new_project: false,
np_name: String::new(),
np_author: String::new(),
np_description: String::new(),
np_parent: String::new(),
np_status: String::new(),
np_rx: None,
show_project_settings: false,
}; };
app.load_spell_dict(); app.load_spell_dict();
app.open_workspace(); app.open_workspace();
@@ -375,6 +406,7 @@ impl eframe::App for App {
self.poll_settings_test(); self.poll_settings_test();
self.poll_spell(); self.poll_spell();
self.poll_beats(); self.poll_beats();
self.poll_new_project();
self.maybe_start_spell_check(ctx); self.maybe_start_spell_check(ctx);
self.menu_bar(ctx); self.menu_bar(ctx);
@@ -422,6 +454,14 @@ impl eframe::App for App {
self.template_settings_window(ctx); self.template_settings_window(ctx);
} }
if self.show_new_project {
self.new_project_window(ctx);
}
if self.show_project_settings {
self.project_settings_window(ctx);
}
if self.beats_output.is_some() { if self.beats_output.is_some() {
self.beats_window(ctx); self.beats_window(ctx);
} }
+450
View File
@@ -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;
}
}
+15
View File
@@ -221,6 +221,17 @@ impl App {
egui::TopBottomPanel::top("menubar").show(ctx, |ui| { egui::TopBottomPanel::top("menubar").show(ctx, |ui| {
egui::menu::bar(ui, |ui| { egui::menu::bar(ui, |ui| {
ui.menu_button("File", |ui| { ui.menu_button("File", |ui| {
if ui
.button("✨ New project…")
.on_hover_text(
"Scaffold a project from the cookiecutter template and open its drafting folder",
)
.clicked()
{
ui.close_menu();
self.open_new_project();
}
ui.separator();
if ui.button("📂 Open workspace…").clicked() { if ui.button("📂 Open workspace…").clicked() {
ui.close_menu(); ui.close_menu();
self.browse_workspace(); self.browse_workspace();
@@ -277,6 +288,10 @@ impl App {
ui.close_menu(); ui.close_menu();
self.show_template_settings = true; self.show_template_settings = true;
} }
if ui.button("New project…").clicked() {
ui.close_menu();
self.show_project_settings = true;
}
}); });
ui.menu_button("Help", |ui| { ui.menu_button("Help", |ui| {
if ui.button("📝 Markdown cheatsheet").clicked() { if ui.button("📝 Markdown cheatsheet").clicked() {
+9
View File
@@ -58,6 +58,15 @@ pub(super) fn civil_from_days(days: i64) -> (i64, u32, u32) {
(if m <= 2 { y + 1 } else { y }, m, d) (if m <= 2 { y + 1 } else { y }, m, d)
} }
// The workspace-relative path helpers live in `crate::order`, which defines
// that string form in the first place (it is what `order.json` stores). They
// are re-exported here so the UI modules pick them up through `use super::*`
// alongside the formatting helpers below.
pub(super) use crate::order::{
base_name, is_within, join_rel, parent_dir, sanitize_rel_path, strip_md,
};
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+215 -45
View File
@@ -31,6 +31,7 @@ impl App {
self.file_meta = self.snapshot_file_meta(); self.file_meta = self.snapshot_file_meta();
self.rebuild_field_names(); self.rebuild_field_names();
self.autocomplete = None; self.autocomplete = None;
self.collapsed.clear();
if !self.files.is_empty() { if !self.files.is_empty() {
self.select(0); self.select(0);
} }
@@ -188,67 +189,108 @@ impl App {
self.spell_dirty = true; self.spell_dirty = true;
self.spell_last_edit = None; self.spell_last_edit = None;
self.spell_menu = None; self.spell_menu = None;
if let Some(name) = self.files.get(idx) { let Some(name) = self.files.get(idx).cloned() else {
let path = self.path_for(name); return;
};
let path = self.path_for(&name);
self.buffer = std::fs::read_to_string(&path).unwrap_or_default(); self.buffer = std::fs::read_to_string(&path).unwrap_or_default();
self.selected = Some(idx); self.selected = Some(idx);
self.dirty = false; self.dirty = false;
self.pending_delete = false; self.pending_delete = false;
self.rename_input = Path::new(name) // The rename box holds the whole workspace-relative path, so it doubles
.file_stem() // as the way to move a file between folders by typing.
.and_then(|s| s.to_str()) self.rename_input = strip_md(&name).to_string();
.unwrap_or("") self.title_input = self.titles.get(&name).cloned().unwrap_or_default();
.to_string(); self.reveal(&name);
self.title_input = self.titles.get(name).cloned().unwrap_or_default();
} }
/// Expand any collapsed folders that would hide `path`, so a file that was
/// just selected or created is actually on screen.
pub(super) fn reveal(&mut self, path: &str) {
self.collapsed.retain(|dir| !is_within(path, dir));
}
/// Move one manuscript file on disk and carry its per-file state — title
/// override, session word baseline, cached header info — across to the new
/// path. The caller is responsible for updating `files`; on error nothing
/// has changed.
fn relocate(&mut self, old: &str, new: &str) -> std::io::Result<()> {
move_manuscript_file(self.workspace(), old, new)?;
if let Some(title) = self.titles.remove(old) {
self.titles.insert(new.to_string(), title);
self.persist_titles();
}
if let Some(words) = self.session_start_counts.remove(old) {
self.session_start_counts.insert(new.to_string(), words);
}
if let Some(meta) = self.file_meta.remove(old) {
self.file_meta.insert(new.to_string(), meta);
}
Ok(())
} }
pub(super) fn create_file(&mut self) { pub(super) fn create_file(&mut self) {
let mut stem = self.new_name.trim().to_string(); // The typed name may carry folders (`part-1/ch-01`), which is the only
if stem.is_empty() { // way new folders come into being — one appears with its first file.
let Some(name) = sanitize_rel_path(&self.new_name) else {
self.status = "Enter a name for the new file".to_string(); self.status = "Enter a name for the new file".to_string();
return; return;
} };
if stem.to_lowercase().ends_with(".md") { let stem = strip_md(base_name(&name)).to_string();
stem.truncate(stem.len() - 3);
}
let seed = format!("# {stem}\n\n"); let seed = format!("# {stem}\n\n");
self.insert_new_file(format!("{stem}.md"), seed); self.insert_new_file(name, seed);
} }
/// Create a file seeded from the configured template, naming it /// Create a file seeded from the configured template, naming it
/// `untitled-N.md` so the button works without typing a name first. /// `untitled-N.md` so the button works without typing a name first. It lands
/// beside the file being edited, so working inside a part folder doesn't
/// scatter untitled files back at the workspace root.
pub(super) fn create_file_from_template(&mut self) { pub(super) fn create_file_from_template(&mut self) {
let dir = self
.selected
.and_then(|i| self.files.get(i))
.map(|path| parent_dir(path).to_string())
.unwrap_or_default();
let existing: std::collections::HashSet<String> = let existing: std::collections::HashSet<String> =
self.files.iter().map(|n| n.to_lowercase()).collect(); self.files.iter().map(|n| n.to_lowercase()).collect();
let name = next_untitled_name(|candidate| { let leaf = next_untitled_name(|candidate| {
existing.contains(&candidate.to_lowercase()) || self.path_for(candidate).exists() let full = join_rel(&dir, candidate);
existing.contains(&full.to_lowercase()) || self.path_for(&full).exists()
}); });
let stem = name.trim_end_matches(".md").to_string(); let stem = strip_md(&leaf).to_string();
let seed = render_template( let seed = render_template(
&self.config.effective_new_file_template(), &self.config.effective_new_file_template(),
&stem, &stem,
self.config.draft_marker.trim(), self.config.draft_marker.trim(),
&today_utc(), &today_utc(),
); );
self.insert_new_file(name, seed); self.insert_new_file(join_rel(&dir, &leaf), seed);
// The template's header fields should be offerable straight away. // The template's header fields should be offerable straight away.
self.rebuild_field_names(); self.rebuild_field_names();
} }
/// Write `contents` to a new file, append it to the manuscript order and /// Write `contents` to a new file, add it to the manuscript order and select
/// select it. Shared by the plain and template-backed create paths. /// it. Shared by the plain and template-backed create paths.
fn insert_new_file(&mut self, name: String, contents: String) { fn insert_new_file(&mut self, name: String, contents: String) {
let path = self.path_for(&name); let path = self.path_for(&name);
if path.exists() { if path.exists() {
self.status = format!("{name} already exists"); self.status = format!("{name} already exists");
return; return;
} }
if let Some(parent) = path.parent() {
if let Err(e) = std::fs::create_dir_all(parent) {
self.status = format!("Create failed: {e}");
return;
}
}
match std::fs::write(&path, contents) { match std::fs::write(&path, contents) {
Ok(_) => { Ok(_) => {
self.files.push(name.clone()); self.files.push(name.clone());
// Folder-tree order decides where the newcomer actually lands,
// so normalise before working out which index to select.
self.files = order::tree_order(&self.files);
self.persist_order(); self.persist_order();
let idx = self.files.len() - 1; let idx = self.files.iter().position(|f| *f == name).unwrap_or(0);
self.selected = None; // force reload of buffer self.selected = None; // force reload of buffer
self.select(idx); self.select(idx);
self.new_name.clear(); self.new_name.clear();
@@ -270,6 +312,8 @@ impl App {
self.file_meta.remove(&name); self.file_meta.remove(&name);
self.persist_titles(); self.persist_titles();
self.persist_order(); self.persist_order();
// The folder may have held nothing else.
prune_empty_dirs(self.workspace(), parent_dir(&name));
self.selected = None; self.selected = None;
self.buffer.clear(); self.buffer.clear();
self.dirty = false; self.dirty = false;
@@ -287,42 +331,30 @@ impl App {
pub(super) fn rename_selected(&mut self) { pub(super) fn rename_selected(&mut self) {
let Some(idx) = self.selected else { return }; let Some(idx) = self.selected else { return };
let mut stem = self.rename_input.trim().to_string(); // A path in the box (`part-2/ch-07`) both renames and moves the file.
if stem.to_lowercase().ends_with(".md") { let Some(new_name) = sanitize_rel_path(&self.rename_input) else {
stem.truncate(stem.len() - 3);
}
if stem.is_empty() {
self.status = "Enter a new name".to_string(); self.status = "Enter a new name".to_string();
return; return;
} };
let new_name = format!("{stem}.md");
let Some(old_name) = self.files.get(idx).cloned() else { let Some(old_name) = self.files.get(idx).cloned() else {
return; return;
}; };
if new_name == old_name { if new_name == old_name {
return; return;
} }
let new_path = self.path_for(&new_name); if self.path_for(&new_name).exists() {
if new_path.exists() {
self.status = format!("{new_name} already exists"); self.status = format!("{new_name} already exists");
return; return;
} }
// Persist any pending edits under the old name first. // Persist any pending edits under the old name first.
self.save_current(); self.save_current();
match std::fs::rename(self.path_for(&old_name), &new_path) { match self.relocate(&old_name, &new_name) {
Ok(_) => { Ok(_) => {
self.files[idx] = new_name.clone(); self.files[idx] = new_name.clone();
if let Some(title) = self.titles.remove(&old_name) { self.files = order::tree_order(&self.files);
self.titles.insert(new_name.clone(), title); self.selected = self.files.iter().position(|f| *f == new_name);
self.persist_titles();
}
if let Some(words) = self.session_start_counts.remove(&old_name) {
self.session_start_counts.insert(new_name.clone(), words);
}
if let Some(meta) = self.file_meta.remove(&old_name) {
self.file_meta.insert(new_name.clone(), meta);
}
self.persist_order(); self.persist_order();
self.reveal(&new_name);
self.status = format!("Renamed to {new_name}"); self.status = format!("Renamed to {new_name}");
} }
Err(e) => self.status = format!("Rename failed: {e}"), Err(e) => self.status = format!("Rename failed: {e}"),
@@ -475,8 +507,40 @@ impl App {
} }
} }
/// Apply a file-panel drag-and-drop: move the file into the drop's folder on
/// disk when that changed, then reposition it in the manuscript order.
pub(super) fn apply_drop(&mut self, drop: FileDrop) {
let FileDrop { from, to, dir } = drop;
let Some(old) = self.files.get(from).cloned() else {
return;
};
if parent_dir(&old) == dir {
self.status = "Reordered".to_string();
} else {
let new = join_rel(&dir, base_name(&old));
if self.path_for(&new).exists() {
self.status = format!("{new} already exists");
return;
}
// Flush pending edits under the old name before the file moves.
self.save_current();
if let Err(e) = self.relocate(&old, &new) {
self.status = format!("Move failed: {e}");
return;
}
self.files[from] = new;
self.status = match dir.as_str() {
"" => format!("Moved {} to the workspace root", base_name(&old)),
dir => format!("Moved {} into {dir}", base_name(&old)),
};
}
self.reorder(from, to);
}
/// Move the file at `from` to flat position `to`, then re-normalise into
/// folder-tree order so the panel and the export stay in step.
pub(super) fn reorder(&mut self, from: usize, mut to: usize) { pub(super) fn reorder(&mut self, from: usize, mut to: usize) {
if from >= self.files.len() || from == to { if from >= self.files.len() {
return; return;
} }
// Remember the selected file by name so selection follows the move. // Remember the selected file by name so selection follows the move.
@@ -488,12 +552,12 @@ impl App {
} }
to = to.min(self.files.len()); to = to.min(self.files.len());
self.files.insert(to, item); self.files.insert(to, item);
self.files = order::tree_order(&self.files);
self.persist_order(); self.persist_order();
if let Some(name) = selected_name { if let Some(name) = selected_name {
self.selected = self.files.iter().position(|n| *n == name); self.selected = self.files.iter().position(|n| *n == name);
} }
self.status = "Reordered".to_string();
} }
/// Settings dialog for the markdown seeded into template-backed new files. /// Settings dialog for the markdown seeded into template-backed new files.
@@ -576,6 +640,36 @@ impl App {
} }
} }
/// Delete `dir` (workspace-relative) and every parent it leaves childless, so
/// the tree stops drawing branches nothing lives in any more. `remove_dir`
/// refuses to touch a non-empty directory, which is exactly the guard wanted
/// here; an empty `dir` is the workspace itself and is left alone.
pub(super) fn prune_empty_dirs(workspace: &Path, dir: &str) {
let mut dir = dir;
while !dir.is_empty() {
if std::fs::remove_dir(workspace.join(dir)).is_err() {
break;
}
dir = parent_dir(dir);
}
}
/// Move a manuscript file within the workspace, creating the destination folder
/// and pruning the source folder when the move empties it.
pub(super) fn move_manuscript_file(
workspace: &Path,
old: &str,
new: &str,
) -> std::io::Result<()> {
let new_path = workspace.join(new);
if let Some(parent) = new_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::rename(workspace.join(old), &new_path)?;
prune_empty_dirs(workspace, parent_dir(old));
Ok(())
}
/// First free `untitled-N.md`, so the template button needs no typed name. /// First free `untitled-N.md`, so the template button needs no typed name.
/// `is_taken` reports names already used on disk or in the manuscript order. /// `is_taken` reports names already used on disk or in the manuscript order.
pub(super) fn next_untitled_name(is_taken: impl Fn(&str) -> bool) -> String { pub(super) fn next_untitled_name(is_taken: impl Fn(&str) -> bool) -> String {
@@ -706,4 +800,80 @@ mod tests {
assert!(out.contains("\n### Rough Draft:\n"), "got {out:?}"); assert!(out.contains("\n### Rough Draft:\n"), "got {out:?}");
assert!(!out.contains("{{"), "placeholder left unexpanded in {out:?}"); assert!(!out.contains("{{"), "placeholder left unexpanded in {out:?}");
} }
/// Build a throwaway workspace containing `files` and hand back its path.
fn scratch_ws(tag: &str, files: &[&str]) -> PathBuf {
let dir = std::env::temp_dir().join(format!("md_manuscript_ws_{tag}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for rel in files {
let path = dir.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "x").unwrap();
}
dir
}
#[test]
fn moving_a_file_creates_the_destination_and_clears_the_source_folder() {
let ws = scratch_ws("move", &["part-1/ch-01.md"]);
move_manuscript_file(&ws, "part-1/ch-01.md", "part-2/ch-01.md").unwrap();
assert!(ws.join("part-2/ch-01.md").is_file());
assert!(!ws.join("part-1").exists(), "the emptied folder should go");
let _ = std::fs::remove_dir_all(&ws);
}
#[test]
fn moving_a_file_leaves_a_folder_that_still_holds_others() {
let ws = scratch_ws("move_keep", &["p/a.md", "p/b.md"]);
move_manuscript_file(&ws, "p/a.md", "a.md").unwrap();
assert!(ws.join("a.md").is_file());
assert!(ws.join("p/b.md").is_file());
assert!(ws.join("p").is_dir(), "a folder with files left must survive");
let _ = std::fs::remove_dir_all(&ws);
}
#[test]
fn moving_into_a_folder_that_does_not_exist_yet_creates_it() {
let ws = scratch_ws("move_new", &["a.md"]);
move_manuscript_file(&ws, "a.md", "part-3/deep/a.md").unwrap();
assert!(ws.join("part-3/deep/a.md").is_file());
let _ = std::fs::remove_dir_all(&ws);
}
#[test]
fn moving_a_missing_file_fails_without_disturbing_the_workspace() {
let ws = scratch_ws("move_missing", &["a.md"]);
assert!(move_manuscript_file(&ws, "nope.md", "p/nope.md").is_err());
assert!(ws.join("a.md").is_file());
let _ = std::fs::remove_dir_all(&ws);
}
#[test]
fn pruning_walks_up_through_every_folder_it_empties() {
let ws = scratch_ws("prune", &["a/b/c/only.md", "keep.md"]);
std::fs::remove_file(ws.join("a/b/c/only.md")).unwrap();
prune_empty_dirs(&ws, "a/b/c");
assert!(!ws.join("a").exists(), "the whole empty chain should go");
assert!(ws.join("keep.md").is_file());
let _ = std::fs::remove_dir_all(&ws);
}
#[test]
fn pruning_stops_at_the_first_folder_still_holding_something() {
let ws = scratch_ws("prune_stop", &["a/keep.md", "a/b/gone.md"]);
std::fs::remove_file(ws.join("a/b/gone.md")).unwrap();
prune_empty_dirs(&ws, "a/b");
assert!(!ws.join("a/b").exists());
assert!(ws.join("a").is_dir(), "`a` still holds keep.md");
let _ = std::fs::remove_dir_all(&ws);
}
#[test]
fn pruning_never_removes_the_workspace_itself() {
let ws = scratch_ws("prune_root", &[]);
prune_empty_dirs(&ws, "");
assert!(ws.is_dir());
let _ = std::fs::remove_dir_all(&ws);
}
} }
+120
View File
@@ -68,6 +68,69 @@ pub struct Config {
/// and `{{date}}` to today's UTC date. Blank = the built-in default. /// and `{{date}}` to today's UTC date. Blank = the built-in default.
#[serde(default = "default_new_file_template")] #[serde(default = "default_new_file_template")]
pub new_file_template: String, 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 /// 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_model: default_mistral_model(),
mistral_base_url: default_mistral_base_url(), mistral_base_url: default_mistral_base_url(),
new_file_template: default_new_file_template(), 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"); let back: Config = serde_json::from_str(&text).expect("deserialize");
assert_eq!(back.new_file_template, cfg.new_file_template); 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");
}
} }
+411
View File
@@ -0,0 +1,411 @@
//! Driving the `cookiecutter` CLI to scaffold a new manuscript project.
//!
//! Cookiecutter is a Python tool that renders a directory template through
//! Jinja, so this module shells out to it rather than reimplementing it. The
//! work here is finding the executable (it is usually installed in a conda or
//! per-user prefix that a desktop-launched app does not have on `PATH`),
//! building the non-interactive command line, and working out which directory
//! the run produced.
use std::collections::HashSet;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Command;
/// Name of the executable looked for when no explicit path is configured.
const BIN_NAME: &str = "cookiecutter";
/// Prefixes searched for the executable when `PATH` does not have it, in order.
/// Cookiecutter usually arrives via conda or `pip install --user`, neither of
/// which is on the `PATH` of a process started from a desktop launcher.
const FALLBACK_PREFIXES: &[&str] = &[
".anaconda3/bin",
"anaconda3/bin",
"miniconda3/bin",
"miniforge3/bin",
".local/bin",
];
/// Absolute directories searched after the per-user prefixes.
const SYSTEM_DIRS: &[&str] = &["/usr/local/bin", "/usr/bin"];
/// One `key=value` override handed to cookiecutter.
pub struct Var {
pub key: String,
pub value: String,
}
impl Var {
pub fn new(key: &str, value: &str) -> Self {
Var {
key: key.to_string(),
value: value.to_string(),
}
}
}
/// Everything one generation run needs.
pub struct Request {
/// The `cookiecutter` executable, already resolved.
pub bin: PathBuf,
/// Directory holding the template's `cookiecutter.json`.
pub template: PathBuf,
/// Directory the new project is created inside.
pub output_dir: PathBuf,
/// Template variable overrides (`project_name=…`).
pub vars: Vec<Var>,
/// Environment given to the child, on top of the inherited one. Used for the
/// credentials a template's hooks read.
pub env: Vec<(String, String)>,
/// Whether to run the template's pre/post-generation hooks.
pub run_hooks: bool,
}
/// Look for the `cookiecutter` executable.
///
/// `configured` wins when it is set: a value containing a separator is taken as
/// a path, anything else as a program name to look up. With no configuration,
/// `PATH` is searched first, then the usual per-user Python prefixes.
pub fn resolve_binary(configured: &str, home: Option<&Path>) -> Option<PathBuf> {
let configured = configured.trim();
if !configured.is_empty() {
if configured.contains('/') {
let path = PathBuf::from(configured);
return is_program(&path).then_some(path);
}
return search_path(configured);
}
if let Some(found) = search_path(BIN_NAME) {
return Some(found);
}
let user_dirs = home.into_iter().flat_map(|home| {
FALLBACK_PREFIXES
.iter()
.map(move |prefix| home.join(prefix))
});
let system_dirs = SYSTEM_DIRS.iter().map(PathBuf::from);
user_dirs
.chain(system_dirs)
.map(|dir| dir.join(BIN_NAME))
.find(|candidate| is_program(candidate))
}
/// Whether `path` is an existing file we could execute.
fn is_program(path: &Path) -> bool {
path.is_file()
}
/// Find `name` in the `PATH` environment variable.
fn search_path(name: &str) -> Option<PathBuf> {
let path = std::env::var_os("PATH")?;
std::env::split_paths(&path)
.map(|dir| dir.join(name))
.find(|candidate| is_program(candidate))
}
/// Build the command line for a request: the flags, the template, then the
/// variable overrides. Kept separate from running it so the argument order can
/// be asserted in tests without invoking Python.
pub fn build_args(request: &Request) -> Vec<OsString> {
let mut args: Vec<OsString> = vec![
// Every value comes from the dialog, so the child must never block on a
// prompt — there is no terminal attached to read it.
"--no-input".into(),
"--output-dir".into(),
request.output_dir.clone().into(),
"--accept-hooks".into(),
if request.run_hooks { "yes" } else { "no" }.into(),
request.template.clone().into(),
];
for var in &request.vars {
args.push(format!("{}={}", var.key, var.value).into());
}
args
}
/// Directory entry names directly inside `dir`, or an empty set if unreadable.
fn entries(dir: &Path) -> HashSet<OsString> {
match std::fs::read_dir(dir) {
Ok(read) => read.flatten().map(|e| e.file_name()).collect(),
Err(_) => HashSet::new(),
}
}
/// Run cookiecutter and return the directory it created.
///
/// The new project is identified by diffing the output directory before and
/// after, which works whatever a template names its root — the rendered folder
/// name is a Jinja expression this app never sees.
pub fn generate(request: &Request) -> Result<PathBuf, String> {
if !request.template.join("cookiecutter.json").is_file() {
return Err(format!(
"No cookiecutter.json in {} — that folder is not a template.",
request.template.display()
));
}
std::fs::create_dir_all(&request.output_dir)
.map_err(|e| format!("Cannot use {}: {e}", request.output_dir.display()))?;
let before = entries(&request.output_dir);
let mut command = Command::new(&request.bin);
command.args(build_args(request));
// A template's hooks call out to other programs (git, curl); when
// cookiecutter lives in a conda prefix, so do they, and that prefix is not
// otherwise on the child's PATH.
if let Some(bin_dir) = request.bin.parent() {
command.env("PATH", prepend_path(bin_dir));
}
for (key, value) in &request.env {
command.env(key, value);
}
let output = command.output().map_err(|e| {
format!("Could not run {}: {e}", request.bin.display())
})?;
if !output.status.success() {
return Err(describe_failure(&output));
}
let after = entries(&request.output_dir);
let mut created: Vec<OsString> = after.difference(&before).cloned().collect();
if created.len() == 1 {
return Ok(request.output_dir.join(created.remove(0)));
}
// Nothing new means the template re-rendered over a directory that was
// already there; fall back to the name the project was asked to have.
if created.is_empty() {
if let Some(name) = request.vars.iter().find(|v| v.key == "project_name") {
let guess = request.output_dir.join(&name.value);
if guess.is_dir() {
return Ok(guess);
}
}
}
Err(format!(
"cookiecutter finished but did not leave a single new folder in {}.",
request.output_dir.display()
))
}
/// `dir` followed by the inherited `PATH`.
fn prepend_path(dir: &Path) -> OsString {
let mut dirs = vec![dir.to_path_buf()];
if let Some(existing) = std::env::var_os("PATH") {
dirs.extend(std::env::split_paths(&existing));
}
std::env::join_paths(dirs).unwrap_or_else(|_| dir.as_os_str().to_owned())
}
/// Turn a failed run into a one-line message, preferring the last meaningful
/// line of stderr — cookiecutter puts the real cause there (a JSON decoding
/// error, a hook that exited non-zero) under a long Python traceback.
fn describe_failure(output: &std::process::Output) -> String {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
let detail = last_meaningful_line(&stderr)
.or_else(|| last_meaningful_line(&stdout))
.unwrap_or_else(|| "no output".to_string());
format!("cookiecutter failed: {detail}")
}
/// The last non-blank line that is not part of a Python traceback frame.
fn last_meaningful_line(text: &str) -> Option<String> {
text.lines()
.map(str::trim)
.rfind(|line| {
!line.is_empty()
&& !line.starts_with("File \"")
&& !line.starts_with('^')
&& !line.starts_with('~')
&& *line != "Traceback (most recent call last):"
})
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::*;
fn request(run_hooks: bool) -> Request {
Request {
bin: PathBuf::from("/usr/bin/cookiecutter"),
template: PathBuf::from("/tmpl/snowflake"),
output_dir: PathBuf::from("/out"),
vars: vec![
Var::new("project_name", "The Winter Gate"),
Var::new("author", "Lane Wight"),
],
env: Vec::new(),
run_hooks,
}
}
#[test]
fn args_are_non_interactive_and_carry_the_overrides() {
let args: Vec<String> = build_args(&request(true))
.iter()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_eq!(
args,
vec![
"--no-input",
"--output-dir",
"/out",
"--accept-hooks",
"yes",
"/tmpl/snowflake",
"project_name=The Winter Gate",
"author=Lane Wight",
]
);
}
#[test]
fn hooks_can_be_turned_off() {
let args: Vec<String> = build_args(&request(false))
.iter()
.map(|a| a.to_string_lossy().into_owned())
.collect();
let at = args.iter().position(|a| a == "--accept-hooks").unwrap();
assert_eq!(args[at + 1], "no");
}
/// The template argument has to precede the `key=value` overrides, or
/// cookiecutter reads the first override as the template to render.
#[test]
fn the_template_comes_before_the_overrides() {
let args: Vec<String> = build_args(&request(true))
.iter()
.map(|a| a.to_string_lossy().into_owned())
.collect();
let template = args.iter().position(|a| a == "/tmpl/snowflake").unwrap();
let first_var = args
.iter()
.position(|a| a.starts_with("project_name="))
.unwrap();
assert!(template < first_var, "got {args:?}");
}
#[test]
fn a_value_with_spaces_stays_one_argument() {
let args = build_args(&request(true));
assert!(args
.iter()
.any(|a| a.to_string_lossy() == "project_name=The Winter Gate"));
}
#[test]
fn generate_rejects_a_folder_that_is_not_a_template() {
let dir = std::env::temp_dir().join("md_manuscript_cc_not_a_template");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let mut req = request(false);
req.template = dir.clone();
let err = generate(&req).unwrap_err();
assert!(err.contains("not a template"), "got {err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn an_explicit_binary_path_is_used_when_it_exists() {
let dir = std::env::temp_dir().join("md_manuscript_cc_bin");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let bin = dir.join("cookiecutter");
std::fs::write(&bin, "#!/bin/sh\n").unwrap();
assert_eq!(
resolve_binary(bin.to_str().unwrap(), None),
Some(bin.clone())
);
// A configured path that is not there resolves to nothing rather than
// silently falling back to some other cookiecutter.
assert_eq!(resolve_binary("/nope/cookiecutter", None), None);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn a_per_user_python_prefix_is_searched_when_path_lacks_it() {
let home = std::env::temp_dir().join("md_manuscript_cc_home");
let _ = std::fs::remove_dir_all(&home);
let bin_dir = home.join(".anaconda3/bin");
std::fs::create_dir_all(&bin_dir).unwrap();
let bin = bin_dir.join("cookiecutter");
std::fs::write(&bin, "#!/bin/sh\n").unwrap();
// Only reached because the name is not on this test process's PATH.
if search_path(BIN_NAME).is_none() {
assert_eq!(resolve_binary("", Some(&home)), Some(bin));
}
let _ = std::fs::remove_dir_all(&home);
}
#[test]
fn failure_text_skips_the_python_traceback() {
let text = "Traceback (most recent call last):\n \
File \"/x/hooks.py\", line 1, in run\n \
^^^^^\n\
jinja2.exceptions.TemplateSyntaxError: expected token\n";
assert_eq!(
last_meaningful_line(text).as_deref(),
Some("jinja2.exceptions.TemplateSyntaxError: expected token")
);
}
#[test]
fn failure_text_handles_output_with_nothing_useful() {
assert_eq!(last_meaningful_line(""), None);
assert_eq!(last_meaningful_line(" \n\n \n"), None);
}
/// Drive the real `cookiecutter` against the real template, which is the
/// only check that the argument list above is one the tool actually accepts.
/// Skips when either is absent, so a machine without them still passes.
///
/// Hooks are off here to keep the test hermetic: the snowflake template's
/// post-gen hook publishes to a Gitea server when `GITEA_*` is set, and a
/// test must not depend on — or touch — the network.
#[test]
fn generates_a_project_from_the_configured_template() {
let Some(bin) = resolve_binary("", dirs::home_dir().as_deref()) else {
eprintln!("skipping: no cookiecutter on this machine");
return;
};
let template = crate::config::default_project_template();
if !template.join("cookiecutter.json").is_file() {
eprintln!("skipping: no template at {}", template.display());
return;
}
let out = std::env::temp_dir().join("md_manuscript_cc_e2e");
let _ = std::fs::remove_dir_all(&out);
let request = Request {
bin,
template,
output_dir: out.clone(),
vars: vec![
Var::new("project_name", "Test Project With Spaces"),
Var::new("author", "Nobody"),
Var::new("description", "Generated by a unit test."),
],
env: Vec::new(),
run_hooks: false,
};
let project = generate(&request).expect("generation should succeed");
// The diff-the-directory approach must find the folder the template
// named from `project_name`, spaces and all.
assert_eq!(
project.file_name().and_then(|n| n.to_str()),
Some("Test Project With Spaces")
);
assert!(project.is_dir());
// And the subfolder the app opens as a workspace has to be in there.
let subdir = crate::config::default_project_open_subdir();
assert!(
project.join(&subdir).is_dir(),
"expected a {subdir} folder in {project:?}"
);
let _ = std::fs::remove_dir_all(&out);
}
}
+1
View File
@@ -4,6 +4,7 @@
mod app; mod app;
mod config; mod config;
mod cookiecutter;
mod gitsync; mod gitsync;
mod help; mod help;
mod langtool; mod langtool;
+457 -16
View File
@@ -41,29 +41,210 @@ pub fn write_order(workspace: &Path, order: &[String]) -> std::io::Result<()> {
std::fs::write(path, text) std::fs::write(path, text)
} }
/// List all `*.md` files currently present in the workspace (file names only). /// Directory names the recursive scan never descends into. `.git` and other
/// dot-directories are skipped wholesale; these two are ordinary names that
/// nonetheless only ever hold generated files.
const SKIP_DIRS: &[&str] = &["target", "node_modules"];
/// How far below the workspace the scan looks for manuscript files. Deep enough
/// for any sane part/chapter/scene layout, and a hard stop should a symlink
/// point a directory back at one of its own ancestors.
const MAX_DEPTH: usize = 8;
/// List every `*.md` file at or below the workspace, as workspace-relative
/// paths with `/` separators (e.g. `part-1/ch-03.md`).
fn list_markdown_files(workspace: &Path) -> Vec<String> { fn list_markdown_files(workspace: &Path) -> Vec<String> {
let mut files = Vec::new(); let mut files = Vec::new();
if let Ok(entries) = std::fs::read_dir(workspace) { collect_markdown(workspace, "", 0, &mut files);
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
if let Some(ext) = path.extension() {
if ext.eq_ignore_ascii_case("md") {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
files.push(name.to_string());
}
}
}
}
}
}
files files
} }
/// Recursive worker for [`list_markdown_files`]. `prefix` is the path of `dir`
/// relative to the workspace ("" at the top).
fn collect_markdown(dir: &Path, prefix: &str, depth: usize, out: &mut Vec<String>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let Some(name) = entry.file_name().to_str().map(str::to_string) else {
continue; // non-UTF-8 name; it could never round-trip through order.json
};
// Hidden entries are infrastructure (`.git`, editor swap files), not prose.
if name.starts_with('.') {
continue;
}
let path = entry.path();
let rel = join_rel(prefix, &name);
if path.is_dir() {
let skipped = SKIP_DIRS.iter().any(|d| d.eq_ignore_ascii_case(&name));
if !skipped && depth < MAX_DEPTH {
collect_markdown(&path, &rel, depth + 1, out);
}
} else if path
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("md"))
{
out.push(rel);
}
}
}
// ---- Workspace-relative path helpers -------------------------------------
//
// Manuscript files are addressed by their path relative to the workspace, with
// `/` separators (`part-1/ch-03.md`), so a bare file name is just the depth-0
// case. These operate on that string form rather than on `Path`, because the
// same strings are what `order.json` stores and what the file panel draws.
/// The folder part of a workspace-relative path; `""` for a root-level file.
pub fn parent_dir(path: &str) -> &str {
match path.rfind('/') {
Some(i) => &path[..i],
None => "",
}
}
/// The final component of a workspace-relative path.
pub fn base_name(path: &str) -> &str {
match path.rfind('/') {
Some(i) => &path[i + 1..],
None => path,
}
}
/// Join a folder and a name; an empty folder means the workspace root.
pub fn join_rel(dir: &str, name: &str) -> String {
if dir.is_empty() {
name.to_string()
} else {
format!("{dir}/{name}")
}
}
/// Drop a trailing `.md` (any casing) from a path, leaving everything else be.
pub fn strip_md(path: &str) -> &str {
match path.rfind('.') {
Some(i) if path[i..].eq_ignore_ascii_case(".md") => &path[..i],
_ => path,
}
}
/// Whether `path` sits anywhere inside the folder `dir` (`""` = the workspace
/// root, which contains everything).
pub fn is_within(path: &str, dir: &str) -> bool {
dir.is_empty() || path.starts_with(dir) && path[dir.len()..].starts_with('/')
}
/// Turn text typed into the new-file or rename box into a safe workspace-relative
/// markdown path. `\\` becomes `/`, blank components and `.`/`..` are dropped so
/// nothing can point outside the workspace, and a `.md` extension is ensured.
/// Returns `None` when nothing usable is left.
pub fn sanitize_rel_path(input: &str) -> Option<String> {
let normalized = input.replace('\\', "/");
let mut parts: Vec<&str> = Vec::new();
for part in normalized.split('/') {
let part = part.trim();
if part.is_empty() || part == "." || part == ".." {
continue;
}
parts.push(part);
}
let stem = strip_md(parts.pop()?);
if stem.is_empty() {
return None;
}
let mut out = parts.join("/");
if !out.is_empty() {
out.push('/');
}
out.push_str(stem);
out.push_str(".md");
Some(out)
}
/// One node of the folder tree that [`tree_order`] sorts.
enum Node {
File { name: String, rank: usize },
Dir { name: String, children: Vec<Node> },
}
impl Node {
/// The earliest input position anywhere under this node — the key its
/// siblings are sorted by, so a folder sits where its first file put it.
fn rank(&self) -> usize {
match self {
Node::File { rank, .. } => *rank,
Node::Dir { children, .. } => {
children.iter().map(Node::rank).min().unwrap_or(usize::MAX)
}
}
}
}
/// File the path (already split into components) into the tree under `children`.
fn insert_node(children: &mut Vec<Node>, parts: &[&str], rank: usize) {
match parts {
[] => {}
[file] => children.push(Node::File {
name: (*file).to_string(),
rank,
}),
[dir, rest @ ..] => {
let existing = children
.iter()
.position(|c| matches!(c, Node::Dir { name, .. } if name == dir));
let at = existing.unwrap_or_else(|| {
children.push(Node::Dir {
name: (*dir).to_string(),
children: Vec::new(),
});
children.len() - 1
});
if let Node::Dir { children, .. } = &mut children[at] {
insert_node(children, rest, rank);
}
}
}
}
/// Emit the tree depth-first, each level in rank order.
fn flatten_nodes(children: &mut [Node], prefix: &str, out: &mut Vec<String>) {
children.sort_by_cached_key(Node::rank);
for node in children.iter_mut() {
match node {
Node::File { name, .. } => out.push(join_rel(prefix, name)),
Node::Dir { name, children } => {
let dir = join_rel(prefix, name);
flatten_nodes(children, &dir, out);
}
}
}
}
/// Normalise workspace-relative paths into folder-tree order: every folder's
/// files become contiguous, each folder sits where its earliest-ordered file
/// put it, and files keep their relative order within a folder.
///
/// This is what makes the file panel and the export agree — the panel draws the
/// tree top to bottom, and the export concatenates this very list, so what you
/// see is the order you get.
pub fn tree_order(paths: &[String]) -> Vec<String> {
let mut roots: Vec<Node> = Vec::new();
for (rank, path) in paths.iter().enumerate() {
let parts: Vec<&str> = path.split('/').filter(|p| !p.is_empty()).collect();
insert_node(&mut roots, &parts, rank);
}
let mut out = Vec::with_capacity(paths.len());
flatten_nodes(&mut roots, "", &mut out);
out
}
/// Produce the effective ordered list of markdown files: /// Produce the effective ordered list of markdown files:
/// 1. saved-order entries that still exist on disk, in saved order /// 1. saved-order entries that still exist on disk, in saved order
/// 2. any new `.md` files not yet in the saved order, appended alphabetically /// 2. any new `.md` files not yet in the saved order, appended alphabetically
///
/// The result is then normalised into folder-tree order, so subdirectories
/// always read as coherent blocks however the saved order was written.
pub fn resolve_order(workspace: &Path) -> Vec<String> { pub fn resolve_order(workspace: &Path) -> Vec<String> {
let saved = read_saved_order(workspace); let saved = read_saved_order(workspace);
let mut present = list_markdown_files(workspace); let mut present = list_markdown_files(workspace);
@@ -80,5 +261,265 @@ pub fn resolve_order(workspace: &Path) -> Vec<String> {
result.push(name); result.push(name);
} }
} }
result tree_order(&result)
}
#[cfg(test)]
mod tests {
use super::*;
fn v(items: &[&str]) -> Vec<String> {
items.iter().map(|s| s.to_string()).collect()
}
#[test]
fn tree_order_leaves_a_flat_list_alone() {
let flat = v(&["b.md", "a.md", "c.md"]);
assert_eq!(tree_order(&flat), flat);
}
#[test]
fn tree_order_makes_a_folders_files_contiguous() {
// `part-1` is interleaved with a root file; it must come out as one block,
// anchored where its earliest member (ch-01) sat.
let mixed = v(&["part-1/ch-01.md", "loose.md", "part-1/ch-02.md"]);
assert_eq!(
tree_order(&mixed),
v(&["part-1/ch-01.md", "part-1/ch-02.md", "loose.md"])
);
}
#[test]
fn tree_order_keeps_the_order_within_a_folder() {
let inner = v(&["p/c.md", "p/a.md", "p/b.md"]);
assert_eq!(tree_order(&inner), inner);
}
#[test]
fn tree_order_sorts_folders_by_their_first_file() {
// `beta` owns position 0, so it precedes `alpha` despite the name.
let mixed = v(&["beta/x.md", "alpha/y.md", "beta/z.md"]);
assert_eq!(
tree_order(&mixed),
v(&["beta/x.md", "beta/z.md", "alpha/y.md"])
);
}
#[test]
fn tree_order_nests_to_any_depth() {
let deep = v(&[
"part-1/ch-01/scene-b.md",
"front.md",
"part-1/ch-01/scene-a.md",
"part-1/ch-02.md",
]);
assert_eq!(
tree_order(&deep),
v(&[
"part-1/ch-01/scene-b.md",
"part-1/ch-01/scene-a.md",
"part-1/ch-02.md",
"front.md",
])
);
}
#[test]
fn tree_order_is_idempotent() {
let messy = v(&[
"a/1.md",
"top.md",
"b/deep/2.md",
"a/3.md",
"b/4.md",
]);
let once = tree_order(&messy);
assert_eq!(tree_order(&once), once, "re-normalising must be a no-op");
}
#[test]
fn tree_order_preserves_every_path() {
let messy = v(&["a/1.md", "top.md", "b/deep/2.md", "a/3.md"]);
let mut got = tree_order(&messy);
let mut want = messy.clone();
got.sort();
want.sort();
assert_eq!(got, want);
}
#[test]
fn tree_order_handles_an_empty_list() {
assert!(tree_order(&[]).is_empty());
}
/// Build a throwaway workspace containing `files` (each seeded with a byte)
/// and hand back its path.
fn scratch(tag: &str, files: &[&str]) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("md_manuscript_order_{tag}"));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
for rel in files {
let path = dir.join(rel);
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "x").unwrap();
}
dir
}
#[test]
fn scan_finds_markdown_below_subfolders() {
let ws = scratch(
"nested",
&["top.md", "part-1/ch-01.md", "part-1/chapter-02/scene-a.md"],
);
let mut found = list_markdown_files(&ws);
found.sort();
assert_eq!(
found,
v(&[
"part-1/ch-01.md",
"part-1/chapter-02/scene-a.md",
"top.md",
])
);
let _ = std::fs::remove_dir_all(&ws);
}
#[test]
fn scan_skips_hidden_generated_and_non_markdown_entries() {
let ws = scratch(
"skips",
&[
"keep.md",
"p/deep.md",
".git/objects/x.md",
".hidden.md",
"target/build.md",
"node_modules/pkg/readme.md",
"notes.txt",
],
);
let mut found = list_markdown_files(&ws);
found.sort();
assert_eq!(found, v(&["keep.md", "p/deep.md"]));
let _ = std::fs::remove_dir_all(&ws);
}
#[test]
fn scan_stops_at_the_depth_cap() {
// Exactly MAX_DEPTH folders down is reachable; one more is not, which is
// what keeps a self-referential symlink from looping forever.
let ok = "a/".repeat(MAX_DEPTH) + "deep.md";
let buried = "a/".repeat(MAX_DEPTH + 1) + "buried.md";
let ws = scratch("depth", &[&ok, &buried]);
let found = list_markdown_files(&ws);
assert!(found.contains(&ok), "expected {ok} in {found:?}");
assert!(!found.contains(&buried), "{buried} is past the cap");
let _ = std::fs::remove_dir_all(&ws);
}
#[test]
fn resolve_order_honours_the_saved_order_and_appends_newcomers() {
let ws = scratch(
"resolve",
&["b.md", "part-1/ch-02.md", "part-1/ch-01.md", "a.md"],
);
write_order(
&ws,
&v(&["part-1/ch-02.md", "b.md", "gone.md", "part-1/ch-01.md"]),
)
.unwrap();
// Saved positions win, unknown entries are dropped, and the folder is
// pulled together where its earliest saved member sat. `a.md` was never
// in the saved order, so it lands last.
assert_eq!(
resolve_order(&ws),
v(&["part-1/ch-02.md", "part-1/ch-01.md", "b.md", "a.md"])
);
let _ = std::fs::remove_dir_all(&ws);
}
#[test]
fn resolve_order_ignores_its_own_bookkeeping_files() {
let ws = scratch("bookkeeping", &["a.md"]);
write_order(&ws, &v(&["a.md"])).unwrap();
write_titles(&ws, &HashMap::new()).unwrap();
// order.json/titles.json are not markdown, so they never join the list.
assert_eq!(resolve_order(&ws), v(&["a.md"]));
let _ = std::fs::remove_dir_all(&ws);
}
#[test]
fn splits_workspace_relative_paths() {
assert_eq!(parent_dir("a.md"), "");
assert_eq!(base_name("a.md"), "a.md");
assert_eq!(parent_dir("part-1/ch-03.md"), "part-1");
assert_eq!(base_name("part-1/ch-03.md"), "ch-03.md");
assert_eq!(parent_dir("a/b/c.md"), "a/b");
assert_eq!(base_name("a/b/c.md"), "c.md");
}
#[test]
fn joins_against_the_root_without_a_leading_slash() {
assert_eq!(join_rel("", "a.md"), "a.md");
assert_eq!(join_rel("p", "a.md"), "p/a.md");
assert_eq!(join_rel("p/q", "a.md"), "p/q/a.md");
}
#[test]
fn strips_a_markdown_extension_case_insensitively() {
assert_eq!(strip_md("a.md"), "a");
assert_eq!(strip_md("a.MD"), "a");
assert_eq!(strip_md("p/a.Md"), "p/a");
// Anything else is left alone, dots included.
assert_eq!(strip_md("a.txt"), "a.txt");
assert_eq!(strip_md("a"), "a");
assert_eq!(strip_md("a.md.md"), "a.md");
}
#[test]
fn containment_treats_the_root_as_holding_everything() {
assert!(is_within("a.md", ""));
assert!(is_within("p/a.md", ""));
assert!(is_within("p/a.md", "p"));
assert!(is_within("p/q/a.md", "p"));
// A shared name prefix is not containment.
assert!(!is_within("part-10/a.md", "part-1"));
assert!(!is_within("a.md", "p"));
}
#[test]
fn sanitize_builds_a_markdown_path_from_typed_text() {
assert_eq!(sanitize_rel_path("chapter"), Some("chapter.md".into()));
assert_eq!(sanitize_rel_path("chapter.md"), Some("chapter.md".into()));
assert_eq!(sanitize_rel_path("chapter.MD"), Some("chapter.md".into()));
assert_eq!(
sanitize_rel_path("part-1/ch-01"),
Some("part-1/ch-01.md".into())
);
// Windows-style separators and stray whitespace are tidied up.
assert_eq!(
sanitize_rel_path(" part-1 \\ ch-01 "),
Some("part-1/ch-01.md".into())
);
}
#[test]
fn sanitize_cannot_escape_the_workspace() {
// `..` and absolute-looking input are stripped to their usable remainder.
assert_eq!(sanitize_rel_path("../../etc/passwd"), Some("etc/passwd.md".into()));
assert_eq!(sanitize_rel_path("/tmp/x"), Some("tmp/x.md".into()));
assert_eq!(sanitize_rel_path("a//b///c"), Some("a/b/c.md".into()));
assert_eq!(sanitize_rel_path("./a"), Some("a.md".into()));
}
#[test]
fn sanitize_rejects_input_with_no_file_name() {
assert_eq!(sanitize_rel_path(""), None);
assert_eq!(sanitize_rel_path(" "), None);
assert_eq!(sanitize_rel_path("///"), None);
assert_eq!(sanitize_rel_path(".."), None);
// A name that is nothing but the extension leaves an empty stem.
assert_eq!(sanitize_rel_path("p/.md"), None);
}
} }