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:
+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());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user