Files
md-manuscript/src/app/file_list.rs
T
landon 043cc692ac Add project-mode tooling: characters, outline, diff and revision status
Rounds out project mode, where the workspace is the project root and one
subfolder holds the manuscript proper:

- Characters and Outline windows, backed by new `characters` and `outline`
  modules that read the cast from character sheets and measure how much of
  the snowflake outline is actually written.
- Edit ▸ Changes… diffs the open file against its last committed version.
- Revision status, per-file and project word counts, an archive action and
  hidden folders in the file panel.
- Chapter-file export alongside the ODT master, richer header parsing, and
  a project word list for names and invented terms.
- The export path now follows the workspace: opening a project points it at
  that project root, keeping a file name you chose yourself and re-deriving
  one that merely echoed the folder it sat in.
- Clicking an issue in the grammar/spelling panel takes the editor to it,
  selecting the words and centring them; applying a suggestion jumps to the
  rewritten text as well.

README covers the new windows and workflows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bSn3Xijp8GofZVUnRX4oq
2026-08-24 19:56:36 -05:00

779 lines
35 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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. The panel resolves its own index against `App::files`, because a
/// status filter means the row list and the file list no longer line up.
File,
}
/// 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,
});
}
}
rows
}
impl App {
pub(super) fn left_pane(&mut self, ctx: &egui::Context) {
egui::SidePanel::left("files")
.resizable(true)
.default_width(260.0)
// A hard ceiling: the panel is sized from its content, so any row
// that asks for more width than there is would otherwise push it
// wider every frame.
.width_range(160.0..=460.0)
.show(ctx, |ui| {
// The default theme renders unselected list rows fairly dim; bump
// the widget text colours so file names stay legible (especially in
// dark mode) without affecting the rest of the app.
boost_list_contrast(ui.visuals_mut());
ui.add_space(4.0);
ui.heading("Files");
ui.label(
egui::RichText::new("drag ⠿ to reorder · drop on a folder to move")
.small()
.weak(),
);
if self.manuscript_dir.is_some() {
let note = if self.config.show_reference_files {
"dimmed = reference, not part of the book"
} else {
"manuscript only · View ▸ Show reference files"
};
ui.label(egui::RichText::new(note).small().weak());
}
let statuses = self.known_statuses();
if !statuses.is_empty() {
ui.horizontal(|ui| {
ui.label(egui::RichText::new("Status:").small().weak());
let current = self
.status_filter
.clone()
.unwrap_or_else(|| "all".to_string());
egui::ComboBox::from_id_salt("status_filter")
.selected_text(egui::RichText::new(current).small())
.show_ui(ui, |ui| {
if ui
.selectable_label(self.status_filter.is_none(), "all")
.clicked()
{
self.status_filter = None;
}
for status in &statuses {
let picked = self
.status_filter
.as_deref()
.is_some_and(|s| s == status);
if ui.selectable_label(picked, status).clicked() {
self.status_filter = Some(status.clone());
}
}
});
});
}
ui.separator();
let mut clicked: Option<usize> = None;
let mut toggled: Option<String> = None;
let mut dropped: Option<FileDrop> = None;
let pointer = ui.input(|i| i.pointer.interact_pos());
// Filtering happens on the flat list, so folders left with no
// files simply stop appearing.
let show_reference = self.config.show_reference_files;
let visible: Vec<String> = self
.files
.iter()
.filter(|name| show_reference || self.is_manuscript(name))
.filter(|name| match &self.status_filter {
None => true,
Some(want) => self
.file_meta
.get(*name)
.and_then(|m| m.status.as_deref())
.is_some_and(|s| s.eq_ignore_ascii_case(want)),
})
.cloned()
.collect();
let rows = build_rows(&visible, &self.collapsed);
// Measured once, from the panel rather than from the scrolled
// content: reading `available_width()` inside a row makes the
// content's width depend on the content's width.
let row_width = ui.available_width();
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 row in &rows {
let indent = row.depth as f32 * INDENT;
match row.kind {
RowKind::Folder { count, first } => {
let open = !self.collapsed.contains(&row.path);
// In a project, one folder holds the book.
let is_manuscript_root = self
.manuscript_dir
.as_deref()
.is_some_and(|d| d == row.path);
let in_book = self.is_manuscript(&row.path);
let header = ui
.horizontal(|ui| {
ui.add_space(indent);
let arrow = if open { "" } else { "" };
let label = format!(
"{arrow} 🗀 {}",
base_name(&row.path)
);
let text = egui::RichText::new(label);
let text = if in_book {
text.strong()
} else {
text.weak()
};
// Sized from the panel and truncated:
// an unconstrained label is as wide as
// its text, and the panel is sized from
// its content, so a long folder name
// would widen the panel and keep it
// widened. The full path is on hover.
let name_w =
(row_width - indent - COUNT_W).max(48.0);
if ui
.add_sized(
[name_w, 20.0],
egui::Button::new(text)
.frame(false)
.truncate(),
)
.on_hover_text(&row.path)
.clicked()
{
toggled = Some(row.path.clone());
}
ui.label(
egui::RichText::new(count.to_string())
.small()
.weak(),
);
if is_manuscript_root {
ui.label(
egui::RichText::new("· manuscript")
.small()
.weak(),
)
.on_hover_text(
"These files are the book: ordered, \
numbered and exported. Everything \
else in the project is reference.",
);
}
})
.response;
// Widened only for hit-testing and painting,
// which cannot affect the layout's width.
let header = full_width_row(ui, &header, &row.path);
if drop_highlight(ui, &header) {
if let Some(payload) =
header.dnd_release_payload::<usize>()
{
// `first` indexes the filtered list.
let to = visible
.get(first)
.and_then(|n| {
self.files.iter().position(|f| f == n)
})
.unwrap_or(self.files.len());
dropped = Some(FileDrop {
from: *payload,
to,
dir: row.path.clone(),
});
}
}
}
RowKind::File => {
let name = row.path.clone();
// `build_rows` indexed the filtered list; the
// rest of the app speaks in `files` indices.
let Some(idx) =
self.files.iter().position(|f| *f == name)
else {
continue;
};
let selected = self.selected == Some(idx);
let in_book = self.is_manuscript(&name);
// 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.document(),
&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 = (row_width
- indent
- HANDLE_W
- reserve)
.max(24.0);
let text = egui::RichText::new(base_name(&name));
let text = if in_book { text } else { text.weak() };
let mut label = ui.add_sized(
[label_w, 20.0],
egui::SelectableLabel::new(selected, text),
);
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,
);
}
if let Some(status) = &meta.status {
ui.label(
egui::RichText::new(status_tag(status))
.small()
.weak(),
)
.on_hover_text(format!("Status: {status}"));
}
})
.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(),
);
})
.response;
let target = full_width_row(ui, &target, "\u{0}root-drop");
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(),
});
}
}
}
});
if let Some(idx) = clicked {
self.select(idx);
}
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();
ui.horizontal(|ui| {
ui.add(
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();
}
});
if ui
.button(" New from template")
.on_hover_text(
"Create untitled-N.md seeded from the template \
(Settings ▸ New-file template…), in the current file's folder",
)
.clicked()
{
self.create_file_from_template();
}
if self.selected.is_some() {
ui.horizontal(|ui| {
ui.add(
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();
}
});
ui.horizontal(|ui| {
if ui
.button("🗄 Archive")
.on_hover_text(
"Move this file into the archive folder and out of \
the manuscript, keeping it on disk",
)
.clicked()
{
self.archive_selected();
}
});
ui.horizontal(|ui| {
if !self.pending_delete {
if ui.button("🗑 Delete").clicked() {
self.pending_delete = true;
}
} else {
ui.label("Delete file?");
if ui
.button(egui::RichText::new("Yes").color(egui::Color32::RED))
.clicked()
{
self.delete_selected();
}
if ui.button("No").clicked() {
self.pending_delete = false;
}
}
});
}
});
}
}
/// 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
}
/// Width the drag handle occupies in a file row, so a row's label can be sized
/// from the panel width rather than from whatever is left of the content.
const HANDLE_W: f32 = 22.0;
/// Room left after a folder's name for its file count and the manuscript tag.
const COUNT_W: f32 = 86.0;
/// A response covering the whole visible width of `ui` at the row's height.
///
/// Rows want to be drop targets across their full width, but *claiming* that
/// width makes the content as wide as the panel, and the panel is sized from
/// its content — which grows it, frame after frame. Interacting with a rect
/// taken from the clip rectangle sidesteps that: it is the visible area, not
/// the content, so it cannot feed back into the layout.
fn full_width_row(ui: &egui::Ui, response: &egui::Response, key: &str) -> egui::Response {
let rect = egui::Rect::from_x_y_ranges(ui.clip_rect().x_range(), response.rect.y_range());
ui.interact(rect, egui::Id::new(("row", key)), egui::Sense::hover())
}
/// A compact badge for a `Status:` value: the first letters of its words, so a
/// long stage name still fits beside a file name.
pub(super) fn status_tag(status: &str) -> String {
let initials: String = status
.split_whitespace()
.filter_map(|w| w.chars().find(|c| c.is_alphanumeric()))
.collect();
if initials.chars().count() >= 2 {
initials.to_uppercase()
} else {
status.chars().take(4).collect::<String>().to_uppercase()
}
}
/// 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).
pub(super) fn resolve_chapter_title(
override_title: Option<&str>,
header_title: Option<&str>,
index: usize,
pad_width: usize,
) -> String {
override_title
.map(str::trim)
.filter(|t| !t.is_empty())
.or_else(|| header_title.map(str::trim).filter(|t| !t.is_empty()))
.map(|t| t.to_string())
.unwrap_or_else(|| format!("{:0width$}.", index + 1, width = pad_width))
}
/// Progress toward a word-count target: the bar fraction (0..=1), a fill colour
/// (amber under the range, green within it, blue over it), and a label like
/// "1,234 / 1,5002,000".
pub(super) fn goal_progress(
goal: crate::preprocess::WordGoal,
prose: usize,
) -> (f32, egui::Color32, String) {
let frac = if goal.max == 0 {
0.0
} else {
(prose as f32 / goal.max as f32).clamp(0.0, 1.0)
};
let color = if prose < goal.min {
egui::Color32::from_rgb(0xC8, 0x8A, 0x2A) // amber: below target
} else if prose <= goal.max {
egui::Color32::from_rgb(0x3F, 0x9E, 0x4F) // green: in range
} else {
egui::Color32::from_rgb(0x3B, 0x82, 0xF6) // blue: over target
};
let target = if goal.min == goal.max {
thousands(goal.max)
} else {
format!("{}{}", thousands(goal.min), thousands(goal.max))
};
(frac, color, format!("{} / {}", thousands(prose), target))
}
/// Paint a compact word-count-target progress bar for one file-list row, with
/// the numeric progress (and target) as a hover tooltip. The colour matches the
/// status-bar bar: amber under target, green in range, blue over.
pub(super) fn row_goal_bar(
ui: &mut egui::Ui,
width: f32,
goal: crate::preprocess::WordGoal,
prose: usize,
) {
let (frac, color, text) = goal_progress(goal, prose);
let (rect, resp) = ui.allocate_exact_size(egui::vec2(width, 8.0), egui::Sense::hover());
let rounding = egui::Rounding::same(2.0);
let track = ui.visuals().extreme_bg_color;
let painter = ui.painter();
painter.rect_filled(rect, rounding, track);
if frac > 0.0 {
let fill = egui::Rect::from_min_size(
rect.min,
egui::vec2((rect.width() * frac).max(1.0), rect.height()),
);
painter.rect_filled(fill, rounding, color);
}
resp.on_hover_text(format!("{text} words"));
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn chapter_title_falls_back_to_position() {
// No override, no header title -> 1-based index with a period (width 1).
assert_eq!(resolve_chapter_title(None, None, 0, 1), "1.");
assert_eq!(resolve_chapter_title(None, None, 2, 1), "3.");
// Header title is used when present.
assert_eq!(resolve_chapter_title(None, Some("The Gate"), 4, 1), "The Gate");
// Override wins over everything, even a header title.
assert_eq!(
resolve_chapter_title(Some("My Override"), Some("The Gate"), 4, 1),
"My Override"
);
// Blank/whitespace override or header title are ignored.
assert_eq!(resolve_chapter_title(Some(" "), None, 1, 1), "2.");
assert_eq!(resolve_chapter_title(Some(""), Some(" "), 6, 1), "7.");
}
#[test]
fn chapter_number_zero_pads_to_width() {
// Width 2 pads single digits; wider numbers are unaffected.
assert_eq!(resolve_chapter_title(None, None, 0, 2), "01.");
assert_eq!(resolve_chapter_title(None, None, 8, 2), "09.");
assert_eq!(resolve_chapter_title(None, None, 11, 2), "12.");
assert_eq!(resolve_chapter_title(None, None, 4, 3), "005.");
// Padding never applies to a real title.
assert_eq!(
resolve_chapter_title(None, Some("The Gate"), 0, 3),
"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 => format!("{}:file:{}", r.depth, r.path),
})
.collect()
}
#[test]
fn flat_files_get_no_folder_rows() {
assert_eq!(
sketch(&["a.md", "b.md"], &[]),
vec!["0:file:a.md", "0:file: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:p/a.md",
"1:file:p/b.md",
"0:file: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:p/q/a.md",
"1:file: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: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: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: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: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:p/a.md",
"0:dir(1,1):q",
"1:file: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());
}
}