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:
@@ -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) {
|
||||
|
||||
+413
-69
@@ -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<String>) -> bool {
|
||||
let mut ancestor = String::new();
|
||||
let mut parts = path.split('/').peekable();
|
||||
while let Some(part) = parts.next() {
|
||||
if parts.peek().is_none() {
|
||||
return false;
|
||||
}
|
||||
if !ancestor.is_empty() {
|
||||
ancestor.push('/');
|
||||
}
|
||||
ancestor.push_str(part);
|
||||
if collapsed.contains(&ancestor) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Turn the flat, folder-tree-ordered file list into the rows to draw, opening
|
||||
/// a folder header wherever the path prefix changes and skipping everything
|
||||
/// inside a collapsed folder.
|
||||
///
|
||||
/// This relies on `files` being in [`crate::order::tree_order`]: because each
|
||||
/// folder's files are contiguous there, a header is needed exactly once, and
|
||||
/// the file that opens it is by construction the folder's first.
|
||||
pub(super) fn build_rows(files: &[String], collapsed: &HashSet<String>) -> Vec<Row> {
|
||||
let mut rows = Vec::new();
|
||||
// The folder components currently open, outermost first.
|
||||
let mut open: Vec<&str> = Vec::new();
|
||||
|
||||
for (idx, path) in files.iter().enumerate() {
|
||||
let mut parts: Vec<&str> = path.split('/').collect();
|
||||
parts.pop(); // the file name itself
|
||||
let shared = open
|
||||
.iter()
|
||||
.zip(&parts)
|
||||
.take_while(|(a, b)| a == b)
|
||||
.count();
|
||||
open.truncate(shared);
|
||||
for dir in &parts[shared..] {
|
||||
open.push(dir);
|
||||
let folder = open.join("/");
|
||||
if hidden_by_collapse(&folder, collapsed) {
|
||||
continue;
|
||||
}
|
||||
// Contiguity makes the run of files under this folder easy to count.
|
||||
let prefix = format!("{folder}/");
|
||||
let count = files[idx..]
|
||||
.iter()
|
||||
.take_while(|f| f.starts_with(&prefix))
|
||||
.count();
|
||||
rows.push(Row {
|
||||
depth: open.len() - 1,
|
||||
kind: RowKind::Folder { count, first: idx },
|
||||
path: folder,
|
||||
});
|
||||
}
|
||||
if !hidden_by_collapse(path, collapsed) {
|
||||
rows.push(Row {
|
||||
path: path.clone(),
|
||||
depth: parts.len(),
|
||||
kind: RowKind::File { idx },
|
||||
});
|
||||
}
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
impl App {
|
||||
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<usize> = None;
|
||||
let mut from_to: Option<(usize, usize)> = None;
|
||||
let mut toggled: Option<String> = None;
|
||||
let mut dropped: Option<FileDrop> = None;
|
||||
let pointer = ui.input(|i| i.pointer.interact_pos());
|
||||
let 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::<usize>()
|
||||
{
|
||||
dropped = Some(FileDrop {
|
||||
from: *payload,
|
||||
to: first,
|
||||
dir: row.path.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
RowKind::File { idx } => {
|
||||
let name = row.path.clone();
|
||||
let selected = self.selected == Some(idx);
|
||||
// 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::<usize>().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<f32>`, 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::<usize>()
|
||||
{
|
||||
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::<usize>() {
|
||||
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<f32>`, 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::<usize>() {
|
||||
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::<usize>() {
|
||||
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::<usize>().is_none() {
|
||||
return false;
|
||||
}
|
||||
ui.painter().rect_stroke(
|
||||
response.rect,
|
||||
egui::Rounding::same(2.0),
|
||||
egui::Stroke::new(2.0_f32, ui.visuals().selection.stroke.color),
|
||||
);
|
||||
true
|
||||
}
|
||||
|
||||
/// Resolve a chapter's title: a non-empty manual `override_title` wins, then the
|
||||
/// `# 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<String> {
|
||||
let files: Vec<String> = files.iter().map(|s| s.to_string()).collect();
|
||||
let collapsed: HashSet<String> = collapsed.iter().map(|s| s.to_string()).collect();
|
||||
build_rows(&files, &collapsed)
|
||||
.iter()
|
||||
.map(|r| match r.kind {
|
||||
RowKind::Folder { count, first } => {
|
||||
format!("{}:dir({count},{first}):{}", r.depth, r.path)
|
||||
}
|
||||
RowKind::File { idx } => format!("{}:file({idx}):{}", r.depth, r.path),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flat_files_get_no_folder_rows() {
|
||||
assert_eq!(
|
||||
sketch(&["a.md", "b.md"], &[]),
|
||||
vec!["0:file(0):a.md", "0:file(1):b.md"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_folder_header_is_opened_once_for_its_run_of_files() {
|
||||
assert_eq!(
|
||||
sketch(&["p/a.md", "p/b.md", "top.md"], &[]),
|
||||
vec![
|
||||
"0:dir(2,0):p",
|
||||
"1:file(0):p/a.md",
|
||||
"1:file(1):p/b.md",
|
||||
"0:file(2):top.md",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_folders_indent_and_count_everything_below_them() {
|
||||
assert_eq!(
|
||||
sketch(&["p/q/a.md", "p/b.md"], &[]),
|
||||
vec![
|
||||
// `p` counts both files; `q` only its own.
|
||||
"0:dir(2,0):p",
|
||||
"1:dir(1,0):p/q",
|
||||
"2:file(0):p/q/a.md",
|
||||
"1:file(1):p/b.md",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsing_a_folder_hides_its_files_but_keeps_its_header() {
|
||||
assert_eq!(
|
||||
sketch(&["p/a.md", "p/b.md", "top.md"], &["p"]),
|
||||
vec!["0:dir(2,0):p", "0:file(2):top.md"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsing_hides_nested_headers_too() {
|
||||
assert_eq!(
|
||||
sketch(&["p/q/a.md", "p/b.md", "top.md"], &["p"]),
|
||||
vec!["0:dir(2,0):p", "0:file(2):top.md"]
|
||||
);
|
||||
// Collapsing only the inner folder leaves the outer one drawn.
|
||||
assert_eq!(
|
||||
sketch(&["p/q/a.md", "p/b.md"], &["p/q"]),
|
||||
vec!["0:dir(2,0):p", "1:dir(1,0):p/q", "1:file(1):p/b.md"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_collapsed_name_only_hides_its_own_children() {
|
||||
// `part-1` must not swallow `part-10`, which merely shares a prefix.
|
||||
assert_eq!(
|
||||
sketch(&["part-1/a.md", "part-10/b.md"], &["part-1"]),
|
||||
vec![
|
||||
"0:dir(1,0):part-1",
|
||||
"0:dir(1,1):part-10",
|
||||
"1:file(1):part-10/b.md",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sibling_folders_each_get_their_own_header() {
|
||||
assert_eq!(
|
||||
sketch(&["p/a.md", "q/b.md"], &[]),
|
||||
vec![
|
||||
"0:dir(1,0):p",
|
||||
"1:file(0):p/a.md",
|
||||
"0:dir(1,1):q",
|
||||
"1:file(1):q/b.md",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
/// The first index a folder header reports is where a drop into that folder
|
||||
/// lands, so it has to point at the folder's own first file.
|
||||
#[test]
|
||||
fn a_folder_header_points_at_its_first_file() {
|
||||
let rows = sketch(&["top.md", "p/a.md", "p/b.md"], &[]);
|
||||
assert_eq!(rows[1], "0:dir(2,1):p");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_list_draws_nothing() {
|
||||
assert!(sketch(&[], &[]).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
+42
-2
@@ -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);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
//! **File ▸ New project…**: scaffolding a manuscript project from a
|
||||
//! cookiecutter template, then opening its drafting subfolder as the workspace.
|
||||
//!
|
||||
//! Generation runs on a worker thread — the template's hooks can reach the
|
||||
//! network — and the UI polls for the result, so the editor stays responsive.
|
||||
|
||||
use super::*;
|
||||
|
||||
/// Result of a background generation: the project directory, or a message.
|
||||
pub(super) type ProjectMsg = Result<PathBuf, String>;
|
||||
|
||||
impl App {
|
||||
/// Open the new-project dialog, prefilled from the last one.
|
||||
pub(super) fn open_new_project(&mut self) {
|
||||
self.np_name.clear();
|
||||
self.np_description.clear();
|
||||
self.np_author = self.config.project_author.clone();
|
||||
self.np_parent = self.config.projects_dir.display().to_string();
|
||||
self.np_status.clear();
|
||||
self.show_new_project = true;
|
||||
}
|
||||
|
||||
/// The environment a template's hooks are given. Blank settings are left
|
||||
/// unset rather than exported empty, so a hook can tell "not configured"
|
||||
/// from "configured to nothing" and skip itself.
|
||||
fn hook_env(&self) -> Vec<(String, String)> {
|
||||
[
|
||||
("GITEA_URL", self.config.gitea_url.trim()),
|
||||
("GITEA_USER", self.config.gitea_user.trim()),
|
||||
("GITEA_TOKEN", self.config.gitea_token.trim()),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|(_, value)| !value.is_empty())
|
||||
.map(|(key, value)| (key.to_string(), value.to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Validate the dialog and kick off generation on a worker thread.
|
||||
fn start_new_project(&mut self, ctx: &egui::Context) {
|
||||
if self.np_rx.is_some() {
|
||||
return; // one already running
|
||||
}
|
||||
let name = self.np_name.trim().to_string();
|
||||
if name.is_empty() {
|
||||
self.np_status = "Give the project a name.".to_string();
|
||||
return;
|
||||
}
|
||||
// The name becomes a directory name, so the separators that would make
|
||||
// it a path have to go.
|
||||
if name.contains('/') || name.contains('\\') {
|
||||
self.np_status = "The project name cannot contain / or \\.".to_string();
|
||||
return;
|
||||
}
|
||||
let parent = PathBuf::from(self.np_parent.trim());
|
||||
if self.np_parent.trim().is_empty() {
|
||||
self.np_status = "Choose a folder to create the project in.".to_string();
|
||||
return;
|
||||
}
|
||||
if parent.join(&name).exists() {
|
||||
self.np_status = format!("{} already exists.", parent.join(&name).display());
|
||||
return;
|
||||
}
|
||||
let template = self.config.project_template.clone();
|
||||
let Some(bin) = crate::cookiecutter::resolve_binary(
|
||||
&self.config.cookiecutter_bin,
|
||||
dirs::home_dir().as_deref(),
|
||||
) else {
|
||||
self.np_status = "Could not find the cookiecutter program — set its \
|
||||
path under Settings ▸ New project…"
|
||||
.to_string();
|
||||
return;
|
||||
};
|
||||
|
||||
// Remember the choices that are worth prefilling next time.
|
||||
self.config.project_author = self.np_author.trim().to_string();
|
||||
self.config.projects_dir = parent.clone();
|
||||
self.config.save();
|
||||
|
||||
let request = crate::cookiecutter::Request {
|
||||
bin,
|
||||
template,
|
||||
output_dir: parent,
|
||||
vars: vec![
|
||||
crate::cookiecutter::Var::new("project_name", &name),
|
||||
crate::cookiecutter::Var::new("author", self.np_author.trim()),
|
||||
crate::cookiecutter::Var::new("description", self.np_description.trim()),
|
||||
],
|
||||
env: self.hook_env(),
|
||||
run_hooks: self.config.project_run_hooks,
|
||||
};
|
||||
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
self.np_rx = Some(rx);
|
||||
self.np_status = format!("Creating {name}…");
|
||||
let ctx = ctx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let _ = tx.send(crate::cookiecutter::generate(&request));
|
||||
ctx.request_repaint();
|
||||
});
|
||||
}
|
||||
|
||||
/// Pick up a finished generation and open the new project.
|
||||
pub(super) fn poll_new_project(&mut self) {
|
||||
let received = self.np_rx.as_ref().and_then(|rx| rx.try_recv().ok());
|
||||
let Some(result) = received else { return };
|
||||
self.np_rx = None;
|
||||
match result {
|
||||
Ok(project) => {
|
||||
self.show_new_project = false;
|
||||
self.np_status.clear();
|
||||
self.open_project(&project);
|
||||
}
|
||||
Err(e) => self.np_status = format!("✖ {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Point the workspace at a generated project: its configured drafting
|
||||
/// subfolder when the template produced one, otherwise the project root.
|
||||
fn open_project(&mut self, project: &Path) {
|
||||
let subdir = self.config.project_open_subdir.trim();
|
||||
let (workspace, note) = match subdir {
|
||||
"" => (project.to_path_buf(), String::new()),
|
||||
sub if project.join(sub).is_dir() => (project.join(sub), String::new()),
|
||||
sub => (
|
||||
project.to_path_buf(),
|
||||
format!(" (no {sub} folder in it, opened the project root)"),
|
||||
),
|
||||
};
|
||||
self.save_current();
|
||||
self.workspace_input = workspace.display().to_string();
|
||||
self.config.workspace = workspace;
|
||||
// Export alongside the new project rather than into the previous one.
|
||||
self.config.export_path = project.join(format!(
|
||||
"{}.odt",
|
||||
project
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("manuscript")
|
||||
));
|
||||
self.export_input = self.config.export_path.display().to_string();
|
||||
self.config.save();
|
||||
self.open_workspace();
|
||||
let name = project
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("project");
|
||||
self.status = format!("Created {name}{note} — {}", self.status);
|
||||
}
|
||||
|
||||
/// The new-project dialog.
|
||||
pub(super) fn new_project_window(&mut self, ctx: &egui::Context) {
|
||||
let mut open = self.show_new_project;
|
||||
let mut close = false;
|
||||
let mut create = false;
|
||||
let mut browse = false;
|
||||
let running = self.np_rx.is_some();
|
||||
|
||||
egui::Window::new("New project")
|
||||
.open(&mut open)
|
||||
.resizable(false)
|
||||
.collapsible(false)
|
||||
.default_width(430.0)
|
||||
.show(ctx, |ui| {
|
||||
ui.add_enabled_ui(!running, |ui| {
|
||||
egui::Grid::new("new_project_grid")
|
||||
.num_columns(2)
|
||||
.spacing([10.0, 8.0])
|
||||
.show(ui, |ui| {
|
||||
ui.label("Name:");
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut self.np_name)
|
||||
.hint_text("The Winter Gate")
|
||||
.desired_width(280.0),
|
||||
);
|
||||
ui.end_row();
|
||||
|
||||
ui.label("Author:");
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut self.np_author)
|
||||
.desired_width(280.0),
|
||||
);
|
||||
ui.end_row();
|
||||
|
||||
ui.label("Description:");
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut self.np_description)
|
||||
.hint_text("A short description of the project.")
|
||||
.desired_width(280.0),
|
||||
);
|
||||
ui.end_row();
|
||||
|
||||
ui.label("Create in:");
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut self.np_parent)
|
||||
.desired_width(240.0),
|
||||
);
|
||||
if ui.button("📂").on_hover_text("Choose folder").clicked() {
|
||||
browse = true;
|
||||
}
|
||||
});
|
||||
ui.end_row();
|
||||
});
|
||||
|
||||
ui.add_space(4.0);
|
||||
let target = PathBuf::from(self.np_parent.trim())
|
||||
.join(self.np_name.trim())
|
||||
.display()
|
||||
.to_string();
|
||||
ui.label(
|
||||
egui::RichText::new(format!("Creates: {target}"))
|
||||
.small()
|
||||
.weak()
|
||||
.monospace(),
|
||||
);
|
||||
let subdir = self.config.project_open_subdir.trim();
|
||||
if !subdir.is_empty() {
|
||||
ui.label(
|
||||
egui::RichText::new(format!(
|
||||
"Then opens its {subdir} folder as the workspace."
|
||||
))
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
}
|
||||
if self.config.project_run_hooks {
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"The template's hooks will run. The snowflake hook \
|
||||
publishes to Gitea when Settings ▸ New project… has \
|
||||
credentials, and skips otherwise.",
|
||||
)
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
if running {
|
||||
ui.add_space(4.0);
|
||||
ui.horizontal(|ui| {
|
||||
ui.spinner();
|
||||
ui.label("Working…");
|
||||
});
|
||||
}
|
||||
if !self.np_status.is_empty() {
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new(&self.np_status).weak());
|
||||
}
|
||||
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
if ui
|
||||
.add_enabled(!running, egui::Button::new("Create"))
|
||||
.clicked()
|
||||
{
|
||||
create = true;
|
||||
}
|
||||
if ui
|
||||
.add_enabled(!running, egui::Button::new("Cancel"))
|
||||
.clicked()
|
||||
{
|
||||
close = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
self.show_new_project = open && !close;
|
||||
if browse {
|
||||
let mut dialog = rfd::FileDialog::new().set_title("Create the project in…");
|
||||
let start = PathBuf::from(self.np_parent.trim());
|
||||
if start.is_dir() {
|
||||
dialog = dialog.set_directory(&start);
|
||||
}
|
||||
if let Some(path) = dialog.pick_folder() {
|
||||
self.np_parent = path.display().to_string();
|
||||
}
|
||||
}
|
||||
if create {
|
||||
self.start_new_project(ctx);
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings for **File ▸ New project…**: which template to render, how to
|
||||
/// run it, and the credentials its hooks read.
|
||||
pub(super) fn project_settings_window(&mut self, ctx: &egui::Context) {
|
||||
let mut open = self.show_project_settings;
|
||||
let mut close = false;
|
||||
let mut browse_template = false;
|
||||
|
||||
egui::Window::new("New-project settings")
|
||||
.open(&mut open)
|
||||
.resizable(false)
|
||||
.collapsible(false)
|
||||
.default_width(470.0)
|
||||
.show(ctx, |ui| {
|
||||
let mut save_now = false;
|
||||
|
||||
ui.label(egui::RichText::new("Template").strong());
|
||||
ui.horizontal(|ui| {
|
||||
let mut template = self.config.project_template.display().to_string();
|
||||
let r = ui.add(
|
||||
egui::TextEdit::singleline(&mut template)
|
||||
.desired_width(330.0),
|
||||
);
|
||||
if r.changed() {
|
||||
self.config.project_template = PathBuf::from(template.trim());
|
||||
}
|
||||
save_now |= r.lost_focus();
|
||||
if ui.button("📂").on_hover_text("Choose folder").clicked() {
|
||||
browse_template = true;
|
||||
}
|
||||
});
|
||||
let template_ok = self
|
||||
.config
|
||||
.project_template
|
||||
.join("cookiecutter.json")
|
||||
.is_file();
|
||||
ui.label(
|
||||
egui::RichText::new(if template_ok {
|
||||
"✔ cookiecutter.json found".to_string()
|
||||
} else {
|
||||
"✖ no cookiecutter.json in that folder".to_string()
|
||||
})
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
|
||||
ui.add_space(6.0);
|
||||
egui::Grid::new("project_settings_grid")
|
||||
.num_columns(2)
|
||||
.spacing([10.0, 8.0])
|
||||
.show(ui, |ui| {
|
||||
ui.label("Open subfolder:");
|
||||
let r = ui.add(
|
||||
egui::TextEdit::singleline(&mut self.config.project_open_subdir)
|
||||
.hint_text("06-First Draft")
|
||||
.desired_width(280.0),
|
||||
);
|
||||
save_now |= r.lost_focus();
|
||||
ui.end_row();
|
||||
|
||||
ui.label("cookiecutter path:");
|
||||
let r = ui.add(
|
||||
egui::TextEdit::singleline(&mut self.config.cookiecutter_bin)
|
||||
.hint_text("blank = search PATH and conda prefixes")
|
||||
.desired_width(280.0),
|
||||
);
|
||||
save_now |= r.lost_focus();
|
||||
ui.end_row();
|
||||
});
|
||||
let found = crate::cookiecutter::resolve_binary(
|
||||
&self.config.cookiecutter_bin,
|
||||
dirs::home_dir().as_deref(),
|
||||
);
|
||||
ui.label(
|
||||
egui::RichText::new(match &found {
|
||||
Some(path) => format!("✔ {}", path.display()),
|
||||
None => "✖ cookiecutter not found".to_string(),
|
||||
})
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
|
||||
ui.add_space(6.0);
|
||||
if ui
|
||||
.checkbox(
|
||||
&mut self.config.project_run_hooks,
|
||||
"Run the template's hooks",
|
||||
)
|
||||
.on_hover_text(
|
||||
"Templates can run scripts after generating. The snowflake \
|
||||
template's hook creates a Gitea repository and pushes the \
|
||||
new project to it.",
|
||||
)
|
||||
.changed()
|
||||
{
|
||||
save_now = true;
|
||||
}
|
||||
|
||||
ui.add_space(6.0);
|
||||
ui.label(egui::RichText::new("Gitea (used by the template's hook)").strong());
|
||||
egui::Grid::new("gitea_grid")
|
||||
.num_columns(2)
|
||||
.spacing([10.0, 8.0])
|
||||
.show(ui, |ui| {
|
||||
ui.label("Server URL:");
|
||||
let r = ui.add(
|
||||
egui::TextEdit::singleline(&mut self.config.gitea_url)
|
||||
.hint_text("https://gitea.example.com")
|
||||
.desired_width(280.0),
|
||||
);
|
||||
save_now |= r.lost_focus();
|
||||
ui.end_row();
|
||||
|
||||
ui.label("User:");
|
||||
let r = ui.add(
|
||||
egui::TextEdit::singleline(&mut self.config.gitea_user)
|
||||
.desired_width(280.0),
|
||||
);
|
||||
save_now |= r.lost_focus();
|
||||
ui.end_row();
|
||||
|
||||
ui.label("Token:");
|
||||
let r = ui.add(
|
||||
egui::TextEdit::singleline(&mut self.config.gitea_token)
|
||||
.password(true)
|
||||
.desired_width(280.0),
|
||||
);
|
||||
save_now |= r.lost_focus();
|
||||
ui.end_row();
|
||||
});
|
||||
ui.label(
|
||||
egui::RichText::new(
|
||||
"Passed to the hook as GITEA_URL / GITEA_USER / GITEA_TOKEN. \
|
||||
Leave blank and the hook skips publishing — the project is \
|
||||
still created. The token is stored in this app's config file \
|
||||
in plain text.",
|
||||
)
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
|
||||
ui.separator();
|
||||
if ui.button("Close").clicked() {
|
||||
close = true;
|
||||
}
|
||||
if save_now {
|
||||
self.config.save();
|
||||
}
|
||||
});
|
||||
|
||||
if browse_template {
|
||||
let mut dialog = rfd::FileDialog::new().set_title("Choose a cookiecutter template");
|
||||
if self.config.project_template.is_dir() {
|
||||
dialog = dialog.set_directory(&self.config.project_template);
|
||||
}
|
||||
if let Some(path) = dialog.pick_folder() {
|
||||
self.config.project_template = path;
|
||||
self.config.save();
|
||||
}
|
||||
}
|
||||
|
||||
let now_open = open && !close;
|
||||
if self.show_project_settings && !now_open {
|
||||
self.config.save();
|
||||
}
|
||||
self.show_project_settings = now_open;
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
+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