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:
+219
-49
@@ -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<String> =
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user