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
+42 -2
View File
@@ -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<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
/// the title is derived automatically at export time.
titles: HashMap<String, String>,
@@ -276,6 +284,20 @@ pub struct App {
beats_output: Option<String>,
/// File stem of the proposal the beats came from, for the default save name.
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 {
@@ -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);
}