diff --git a/README.md b/README.md index 1c5fffc..392d530 100644 --- a/README.md +++ b/README.md @@ -3,16 +3,20 @@ A small desktop application for Debian that treats a directory of markdown files as an ordered manuscript. -* **Left pane** — every `*.md` file in the workspace directory, in a manual order - you set by **dragging the `⠿` handle** up and down. **Hover a file** to see a - tooltip built from its header — the `POV:` line and the `# Slug:` synopsis — - a quick at-a-glance summary of each chapter. +* **Left pane** — every `*.md` file in the workspace, as a collapsible folder + tree, in a manual order you set by **dragging the `⠿` handle** up and down. + Drag a file onto a folder to move it there. **Hover a file** to see a tooltip + 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 preview via the *Preview* checkbox). * **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 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 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 repo; decline to leave the workspace on its own, where **Init git** creates a 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)), 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 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 @@ -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 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 The file list has two create buttons: | Button | Name | Contents | |---|---|---| -| **+ New** | the name you type beside it | `# ` and a blank line | +| **+ New** | the name you type beside it, optionally with folders | `# ` and a blank line | | **+ New from template** | auto-assigned `untitled-N.md` | the configured template | **+ 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 -template, appends it to the manuscript order and selects it. Rename it later with +`untitled-N.md` (reusing a gap if you have deleted one) in the folder of the file +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 from that. @@ -328,10 +421,10 @@ only triggers in the header region, never in the prose below the marker. | Location | Purpose | |---|---| -| `/*.md` | your manuscript files | -| `/order.json` | the manual ordering (committed to git) | -| `/titles.json` | chapter-title overrides (committed to git) | -| `~/.config/md-manuscript/config.json` | last workspace, export path, prefs, new-file template | +| `/**/*.md` | your manuscript files, in folders if you like | +| `/order.json` | the manual ordering, as paths (committed to git) | +| `/titles.json` | chapter-title overrides, keyed by path (committed to git) | +| `~/.config/md-manuscript/config.json` | last workspace, export path, prefs, new-file template, project/Gitea settings | ## Markdown supported in ODT export diff --git a/src/app/beats.rs b/src/app/beats.rs index 8f9eb63..6d5d412 100644 --- a/src/app/beats.rs +++ b/src/app/beats.rs @@ -102,6 +102,7 @@ impl App { Ok(_) => { if !self.files.contains(&name) { self.files.push(name.clone()); + self.files = order::tree_order(&self.files); self.persist_order(); } if let Some(idx) = self.files.iter().position(|f| f == &name) { diff --git a/src/app/file_list.rs b/src/app/file_list.rs index ee48961..a0c793f 100644 --- a/src/app/file_list.rs +++ b/src/app/file_list.rs @@ -1,8 +1,108 @@ -//! The left-hand file list: drag-to-reorder rows, chapter titles and the -//! per-file word-count progress bar. +//! The left-hand file list: a collapsible folder tree of the workspace's +//! markdown files, drag-to-reorder (and drag-to-move) rows, chapter titles and +//! the per-file word-count progress bar. 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) -> 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) -> Vec { + 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 { pub(super) fn left_pane(&mut self, ctx: &egui::Context) { egui::SidePanel::left("files") @@ -16,86 +116,192 @@ impl App { ui.add_space(4.0); ui.heading("Files"); ui.label( - egui::RichText::new("drag ⠿ to reorder") + egui::RichText::new("drag ⠿ to reorder · drop on a folder to move") .small() .weak(), ); ui.separator(); let mut clicked: Option = None; - let mut from_to: Option<(usize, usize)> = None; + let mut toggled: Option = None; + let mut dropped: Option = None; 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() .auto_shrink([false, false]) .max_height(ui.available_height() - 120.0) .show(ui, |ui| { - for idx in 0..self.files.len() { - let name = self.files[idx].clone(); - let selected = self.selected == Some(idx); - // The selected file's fields are read live from the - // buffer (so unsaved edits show); others come from the - // cache filled on open/save. - let meta = if selected { - FileMeta::from_markdown(&self.buffer, &self.config.draft_marker) - } else { - self.file_meta.get(&name).cloned().unwrap_or_default() - }; - let tooltip = meta.tooltip(); - let row = ui - .horizontal(|ui| { - ui.dnd_drag_source( - egui::Id::new(("dnd", &name)), - idx, - |ui| { - ui.label( - egui::RichText::new("⠿").monospace().weak(), + for row in &rows { + 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::() + { + 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); + // The selected file's fields are read live from + // the buffer (so unsaved edits show); others come + // from the cache filled on open/save. + let meta = if selected { + FileMeta::from_markdown( + &self.buffer, + &self.config.draft_marker, + ) + } else { + self.file_meta.get(&name).cloned().unwrap_or_default() + }; + let tooltip = meta.tooltip(); + let file_row = ui + .horizontal(|ui| { + ui.add_space(indent); + ui.dnd_drag_source( + egui::Id::new(("dnd", &name)), + idx, + |ui| { + ui.label( + egui::RichText::new("⠿") + .monospace() + .weak(), + ); + }, + ); + // Reserve room on the right for a per-file + // word-count-target bar when the file sets one. + let bar_w = 44.0; + let reserve = if meta.goal.is_some() { + bar_w + 6.0 + } else { + 0.0 + }; + let label_w = + (ui.available_width() - reserve).max(24.0); + let mut label = ui.add_sized( + [label_w, 20.0], + egui::SelectableLabel::new( + selected, + base_name(&name), + ), + ); + if let Some(tooltip) = &tooltip { + label = label.on_hover_text(tooltip); + } + if label.clicked() { + clicked = Some(idx); + } + if let Some(goal) = meta.goal { + row_goal_bar( + ui, + bar_w, + goal, + meta.prose_words, + ); + } + }) + .response; + + // Drop handling: is a dragged item hovering this row? + if file_row.dnd_hover_payload::().is_some() { + let rect = file_row.rect; + let before = pointer + .map(|p| p.y < rect.center().y) + .unwrap_or(true); + let y = if before { rect.top() } else { rect.bottom() }; + ui.painter().hline( + rect.x_range(), + y, + // `Stroke::new` takes `impl Into`, which + // gives an unsuffixed literal no concrete type + // to infer; suffix it rather than lean on the + // f32 fallback that rustc is removing. + egui::Stroke::new( + 2.0_f32, + ui.visuals().selection.stroke.color, + ), + ); + if let Some(payload) = + file_row.dnd_release_payload::() + { + 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(), ); - // Reserve room on the right for a per-file - // word-count-target bar when the file sets one. - let bar_w = 44.0; - let reserve = if meta.goal.is_some() { bar_w + 6.0 } else { 0.0 }; - let label_w = (ui.available_width() - reserve).max(24.0); - let mut label = ui.add_sized( - [label_w, 20.0], - egui::SelectableLabel::new(selected, &name), - ); - if let Some(tooltip) = &tooltip { - label = label.on_hover_text(tooltip); - } - if label.clicked() { - clicked = Some(idx); - } - if let Some(goal) = meta.goal { - row_goal_bar(ui, bar_w, goal, meta.prose_words); - } + ui.allocate_space(egui::vec2(ui.available_width(), 0.0)); }) .response; - - // Drop handling: is a dragged item hovering this row? - if let Some(_payload) = row.dnd_hover_payload::() { - let rect = row.rect; - let before = pointer - .map(|p| p.y < rect.center().y) - .unwrap_or(true); - let y = if before { rect.top() } else { rect.bottom() }; - ui.painter().hline( - rect.x_range(), - y, - // `Stroke::new` takes `impl Into`, which - // gives an unsuffixed literal no concrete type - // to infer; suffix it rather than lean on the - // f32 fallback that rustc is removing. - egui::Stroke::new( - 2.0_f32, - ui.visuals().selection.stroke.color, - ), - ); - if let Some(payload) = row.dnd_release_payload::() { - let target = if before { idx } else { idx + 1 }; - from_to = Some((*payload, target)); + if drop_highlight(ui, &target) { + if let Some(payload) = target.dnd_release_payload::() { + dropped = Some(FileDrop { + from: *payload, + to: self.files.len(), + dir: String::new(), + }); } } } @@ -104,8 +310,13 @@ impl App { if let Some(idx) = clicked { self.select(idx); } - if let Some((from, to)) = from_to { - self.reorder(from, to); + if let Some(path) = toggled { + if !self.collapsed.remove(&path) { + self.collapsed.insert(path); + } + } + if let Some(drop) = dropped { + self.apply_drop(drop); } ui.separator(); @@ -114,6 +325,10 @@ impl App { egui::TextEdit::singleline(&mut self.new_name) .hint_text("new file name") .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() { self.create_file(); @@ -123,7 +338,7 @@ impl App { .button("+ New from template") .on_hover_text( "Create untitled-N.md seeded from the template \ - (Settings ▸ New-file template…)", + (Settings ▸ New-file template…), in the current file's folder", ) .clicked() { @@ -136,6 +351,10 @@ impl App { egui::TextEdit::singleline(&mut self.rename_input) .hint_text("rename") .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() { 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::().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 /// `# 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). @@ -267,4 +500,115 @@ mod tests { "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 { + let files: Vec = files.iter().map(|s| s.to_string()).collect(); + let collapsed: HashSet = 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()); + } } diff --git a/src/app/mod.rs b/src/app/mod.rs index 83e6fd1..8b6760d 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -9,7 +9,7 @@ use crate::gitsync; use crate::odt::{self, Chapter}; use crate::order; use eframe::egui; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{Duration, Instant}; @@ -19,6 +19,7 @@ mod beats; mod editor; mod file_list; mod find; +mod project; mod grammar; mod spelling; mod style; @@ -31,6 +32,7 @@ mod workspace; use self::autocomplete::*; use self::file_list::*; use self::grammar::*; +use self::project::*; use self::style::*; use self::util::*; @@ -161,8 +163,14 @@ struct IssueItem { pub struct App { 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, + /// Folders the user has collapsed in the file panel, as workspace-relative + /// paths. Purely a view concern, so it is not persisted. + collapsed: HashSet, /// Per-file chapter title overrides (file name -> title). Missing/empty means /// the title is derived automatically at export time. titles: HashMap, @@ -276,6 +284,20 @@ pub struct App { beats_output: Option, /// File stem of the proposal the beats came from, for the default save name. beats_source_stem: Option, + /// 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>, + /// Whether the new-project settings window is open. + show_project_settings: bool, } impl App { @@ -287,6 +309,7 @@ impl App { export_input: config.export_path.display().to_string(), config, files: Vec::new(), + collapsed: HashSet::new(), titles: HashMap::new(), title_input: String::new(), selected: None, @@ -340,6 +363,14 @@ impl App { beats_status: String::new(), beats_output: 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.open_workspace(); @@ -375,6 +406,7 @@ impl eframe::App for App { self.poll_settings_test(); self.poll_spell(); self.poll_beats(); + self.poll_new_project(); self.maybe_start_spell_check(ctx); self.menu_bar(ctx); @@ -422,6 +454,14 @@ impl eframe::App for App { 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() { self.beats_window(ctx); } diff --git a/src/app/project.rs b/src/app/project.rs new file mode 100644 index 0000000..5686460 --- /dev/null +++ b/src/app/project.rs @@ -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; + +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; + } +} diff --git a/src/app/ui.rs b/src/app/ui.rs index f6ff548..b892c33 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -221,6 +221,17 @@ impl App { egui::TopBottomPanel::top("menubar").show(ctx, |ui| { egui::menu::bar(ui, |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() { ui.close_menu(); self.browse_workspace(); @@ -277,6 +288,10 @@ impl App { ui.close_menu(); self.show_template_settings = true; } + if ui.button("New project…").clicked() { + ui.close_menu(); + self.show_project_settings = true; + } }); ui.menu_button("Help", |ui| { if ui.button("📝 Markdown cheatsheet").clicked() { diff --git a/src/app/util.rs b/src/app/util.rs index c30f5b9..034f5d0 100644 --- a/src/app/util.rs +++ b/src/app/util.rs @@ -58,6 +58,15 @@ pub(super) fn civil_from_days(days: i64) -> (i64, u32, u32) { (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)] mod tests { use super::*; diff --git a/src/app/workspace.rs b/src/app/workspace.rs index fb74a21..bed848f 100644 --- a/src/app/workspace.rs +++ b/src/app/workspace.rs @@ -31,6 +31,7 @@ impl App { self.file_meta = self.snapshot_file_meta(); self.rebuild_field_names(); self.autocomplete = None; + self.collapsed.clear(); if !self.files.is_empty() { self.select(0); } @@ -188,67 +189,108 @@ impl App { self.spell_dirty = true; self.spell_last_edit = None; self.spell_menu = None; - if let Some(name) = self.files.get(idx) { - let path = self.path_for(name); - self.buffer = std::fs::read_to_string(&path).unwrap_or_default(); - self.selected = Some(idx); - self.dirty = false; - self.pending_delete = false; - self.rename_input = Path::new(name) - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("") - .to_string(); - self.title_input = self.titles.get(name).cloned().unwrap_or_default(); + let Some(name) = self.files.get(idx).cloned() else { + return; + }; + let path = self.path_for(&name); + self.buffer = std::fs::read_to_string(&path).unwrap_or_default(); + self.selected = Some(idx); + self.dirty = false; + self.pending_delete = false; + // The rename box holds the whole workspace-relative path, so it doubles + // as the way to move a file between folders by typing. + self.rename_input = strip_md(&name).to_string(); + self.title_input = self.titles.get(&name).cloned().unwrap_or_default(); + self.reveal(&name); + } + + /// 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) { - let mut stem = self.new_name.trim().to_string(); - if stem.is_empty() { + // The typed name may carry folders (`part-1/ch-01`), which is the only + // 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(); return; - } - if stem.to_lowercase().ends_with(".md") { - stem.truncate(stem.len() - 3); - } + }; + let stem = strip_md(base_name(&name)).to_string(); 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 - /// `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) { + 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 = self.files.iter().map(|n| n.to_lowercase()).collect(); - let name = next_untitled_name(|candidate| { - existing.contains(&candidate.to_lowercase()) || self.path_for(candidate).exists() + let leaf = next_untitled_name(|candidate| { + 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( &self.config.effective_new_file_template(), &stem, self.config.draft_marker.trim(), &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. self.rebuild_field_names(); } - /// Write `contents` to a new file, append it to the manuscript order and - /// select it. Shared by the plain and template-backed create paths. + /// Write `contents` to a new file, add it to the manuscript order and select + /// it. Shared by the plain and template-backed create paths. fn insert_new_file(&mut self, name: String, contents: String) { let path = self.path_for(&name); if path.exists() { self.status = format!("{name} already exists"); 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) { Ok(_) => { 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(); - 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.select(idx); self.new_name.clear(); @@ -270,6 +312,8 @@ impl App { self.file_meta.remove(&name); self.persist_titles(); self.persist_order(); + // The folder may have held nothing else. + prune_empty_dirs(self.workspace(), parent_dir(&name)); self.selected = None; self.buffer.clear(); self.dirty = false; @@ -287,42 +331,30 @@ impl App { pub(super) fn rename_selected(&mut self) { let Some(idx) = self.selected else { return }; - let mut stem = self.rename_input.trim().to_string(); - if stem.to_lowercase().ends_with(".md") { - stem.truncate(stem.len() - 3); - } - if stem.is_empty() { + // A path in the box (`part-2/ch-07`) both renames and moves the file. + let Some(new_name) = sanitize_rel_path(&self.rename_input) else { self.status = "Enter a new name".to_string(); return; - } - let new_name = format!("{stem}.md"); + }; let Some(old_name) = self.files.get(idx).cloned() else { return; }; if new_name == old_name { return; } - let new_path = self.path_for(&new_name); - if new_path.exists() { + if self.path_for(&new_name).exists() { self.status = format!("{new_name} already exists"); return; } // Persist any pending edits under the old name first. self.save_current(); - match std::fs::rename(self.path_for(&old_name), &new_path) { + match self.relocate(&old_name, &new_name) { Ok(_) => { self.files[idx] = new_name.clone(); - if let Some(title) = self.titles.remove(&old_name) { - self.titles.insert(new_name.clone(), title); - 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.files = order::tree_order(&self.files); + self.selected = self.files.iter().position(|f| *f == new_name); self.persist_order(); + self.reveal(&new_name); self.status = format!("Renamed to {new_name}"); } 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) { - if from >= self.files.len() || from == to { + if from >= self.files.len() { return; } // Remember the selected file by name so selection follows the move. @@ -488,12 +552,12 @@ impl App { } to = to.min(self.files.len()); self.files.insert(to, item); + self.files = order::tree_order(&self.files); self.persist_order(); if let Some(name) = selected_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. @@ -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. /// `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 { @@ -706,4 +800,80 @@ mod tests { assert!(out.contains("\n### Rough Draft:\n"), "got {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); + } } diff --git a/src/config.rs b/src/config.rs index 0586d6f..3d61923 100644 --- a/src/config.rs +++ b/src/config.rs @@ -68,6 +68,69 @@ pub struct Config { /// and `{{date}}` to today's UTC date. Blank = the built-in default. #[serde(default = "default_new_file_template")] pub new_file_template: String, + /// Directory holding the cookiecutter template used by **File ▸ New + /// project…** (the folder containing `cookiecutter.json`). + #[serde(default = "default_project_template")] + pub project_template: PathBuf, + /// Path to the `cookiecutter` executable. Blank means look on `PATH` and in + /// the usual per-user Python prefixes. + #[serde(default)] + pub cookiecutter_bin: String, + /// Subfolder of a freshly generated project to open as the workspace. Blank + /// (or a folder the template did not create) opens the project root. + #[serde(default = "default_project_open_subdir")] + pub project_open_subdir: String, + /// Whether to run the template's pre/post-generation hooks. The snowflake + /// template's post-gen hook publishes the project to Gitea, so this decides + /// whether creating a project also creates a remote repository. + #[serde(default = "default_project_run_hooks")] + pub project_run_hooks: bool, + /// Directory new projects are created in; remembered between runs. + #[serde(default = "default_projects_dir")] + pub projects_dir: PathBuf, + /// Author prefilled into the new-project dialog. + #[serde(default)] + pub project_author: String, + /// Base URL of the Gitea server, passed to a template's hooks as + /// `GITEA_URL`. Empty leaves the variable unset, which the snowflake hook + /// treats as "do not publish". + #[serde(default)] + pub gitea_url: String, + /// Gitea account name, passed to hooks as `GITEA_USER`. + #[serde(default)] + pub gitea_user: String, + /// Gitea API token, passed to hooks as `GITEA_TOKEN`. Stored in plain text, + /// like the Mistral key. + #[serde(default)] + pub gitea_token: String, +} + +/// Default cookiecutter template for **File ▸ New project…**. +pub fn default_project_template() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("Documents") + .join("Cookiecutters") + .join("snowflake") +} + +/// Default subfolder opened after generating a project: the snowflake +/// template's drafting folder, where the chapter files live. +pub fn default_project_open_subdir() -> String { + "06-First Draft".to_string() +} + +/// Templates ship hooks because they are meant to run; honouring them is the +/// default, and an unconfigured hook is expected to skip itself. +pub fn default_project_run_hooks() -> bool { + true +} + +/// Default parent directory for new projects. +pub fn default_projects_dir() -> PathBuf { + dirs::document_dir().unwrap_or_else(|| { + dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")) + }) } /// Default new-file template: the editorial header fields this app already @@ -154,6 +217,15 @@ impl Default for Config { mistral_model: default_mistral_model(), mistral_base_url: default_mistral_base_url(), new_file_template: default_new_file_template(), + project_template: default_project_template(), + cookiecutter_bin: String::new(), + project_open_subdir: default_project_open_subdir(), + project_run_hooks: default_project_run_hooks(), + projects_dir: default_projects_dir(), + project_author: String::new(), + gitea_url: String::new(), + gitea_user: String::new(), + gitea_token: String::new(), } } } @@ -292,4 +364,52 @@ mod tests { let back: Config = serde_json::from_str(&text).expect("deserialize"); assert_eq!(back.new_file_template, cfg.new_file_template); } + + /// A config.json written before **File ▸ New project…** existed. Same risk + /// as above: a `#[serde(default)]` missing from any new field would make the + /// whole file fail to parse and reset the user's settings. + #[test] + fn config_without_the_project_fields_still_loads() { + let old = r####"{ + "workspace": "/home/writer/Manuscript", + "export_path": "/home/writer/Manuscript/book.odt", + "draft_marker": "### Rough Draft:", + "mistral_api_key": "secret" + }"####; + let cfg: Config = serde_json::from_str(old).expect("old config must still parse"); + assert_eq!(cfg.workspace, PathBuf::from("/home/writer/Manuscript")); + assert_eq!(cfg.mistral_api_key, "secret"); + // Every new field arrives at its default rather than blank. + assert_eq!(cfg.project_open_subdir, default_project_open_subdir()); + assert_eq!(cfg.project_template, default_project_template()); + assert_eq!(cfg.projects_dir, default_projects_dir()); + assert!(cfg.project_run_hooks, "hooks default to running"); + // Credentials are absent until set; blank is what tells a hook to skip. + assert!(cfg.gitea_url.is_empty()); + assert!(cfg.gitea_token.is_empty()); + assert!(cfg.cookiecutter_bin.is_empty()); + } + + /// The folder opened after generating a project. It is the snowflake + /// template's drafting folder, and the name contains a space — which has to + /// survive being stored and joined onto the project path. + #[test] + fn the_default_project_subdir_is_the_drafting_folder() { + assert_eq!(default_project_open_subdir(), "06-First Draft"); + let joined = PathBuf::from("/tmp/The Winter Gate").join(default_project_open_subdir()); + assert_eq!(joined, PathBuf::from("/tmp/The Winter Gate/06-First Draft")); + } + + /// A saved subfolder choice must win over the built-in default, so changing + /// the default never silently moves an existing user's projects. + #[test] + fn a_saved_project_subdir_is_honoured() { + let saved = r####"{ + "workspace": "/w", + "export_path": "/w/b.odt", + "project_open_subdir": "05-Plot" + }"####; + let cfg: Config = serde_json::from_str(saved).expect("must parse"); + assert_eq!(cfg.project_open_subdir, "05-Plot"); + } } diff --git a/src/cookiecutter.rs b/src/cookiecutter.rs new file mode 100644 index 0000000..8b1adf9 --- /dev/null +++ b/src/cookiecutter.rs @@ -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, + /// 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 { + 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 { + 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 { + let mut args: Vec = 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 { + 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 { + 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 = 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 { + 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 = 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 = 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 = 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); + } +} diff --git a/src/main.rs b/src/main.rs index e5a94cf..adfac29 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,6 +4,7 @@ mod app; mod config; +mod cookiecutter; mod gitsync; mod help; mod langtool; diff --git a/src/order.rs b/src/order.rs index 38d72b8..4fa7c8d 100644 --- a/src/order.rs +++ b/src/order.rs @@ -41,29 +41,210 @@ pub fn write_order(workspace: &Path, order: &[String]) -> std::io::Result<()> { 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 { let mut files = Vec::new(); - if let Ok(entries) = std::fs::read_dir(workspace) { - 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()); - } - } - } + collect_markdown(workspace, "", 0, &mut 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) { + 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 { + 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 }, +} + +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) } } } - files +} + +/// File the path (already split into components) into the tree under `children`. +fn insert_node(children: &mut Vec, 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) { + 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 { + let mut roots: Vec = 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: /// 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 +/// +/// 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 { let saved = read_saved_order(workspace); let mut present = list_markdown_files(workspace); @@ -80,5 +261,265 @@ pub fn resolve_order(workspace: &Path) -> Vec { result.push(name); } } - result + tree_order(&result) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn v(items: &[&str]) -> Vec { + 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); + } }