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
This commit is contained in:
2026-08-24 19:56:36 -05:00
parent e951d57d45
commit 043cc692ac
23 changed files with 3879 additions and 144 deletions
+73 -6
View File
@@ -16,9 +16,12 @@ use std::time::{Duration, Instant};
mod autocomplete;
mod beats;
mod characters;
mod diff;
mod editor;
mod file_list;
mod find;
mod outline;
mod project;
mod grammar;
mod spelling;
@@ -33,6 +36,7 @@ use self::autocomplete::*;
use self::file_list::*;
use self::grammar::*;
use self::project::*;
use self::spelling::*;
use self::style::*;
use self::util::*;
@@ -70,6 +74,8 @@ struct FileMeta {
goal: Option<crate::preprocess::WordGoal>,
/// Prose (body) word count captured with the rest of this metadata.
prose_words: usize,
/// The `Status:` line, if any — the revision stage this file has reached.
status: Option<String>,
}
impl FileMeta {
@@ -81,14 +87,10 @@ impl FileMeta {
pov: h.pov,
goal: h.goal,
prose_words: count_words(&h.body),
status: crate::preprocess::field(text, marker, "Status"),
}
}
/// Whether this entry carries anything worth caching/showing.
fn has_display(&self) -> bool {
self.slug.is_some() || self.pov.is_some() || self.goal.is_some()
}
/// The tooltip text — a `POV:` line then the slug synopsis — or `None` when
/// the file carries neither field.
fn tooltip(&self) -> Option<String> {
@@ -163,6 +165,10 @@ struct IssueItem {
pub struct App {
config: Config,
/// The manuscript folder within the workspace when the workspace is a
/// project root — files under it are the book, everything else is
/// reference. `None` when the workspace is itself the manuscript.
manuscript_dir: Option<String>,
/// 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
@@ -177,8 +183,11 @@ pub struct App {
/// Editing buffer for the selected file's chapter title override.
title_input: String,
selected: Option<usize>,
/// Editor contents for the selected file.
/// Editor contents for the selected file. While the header is collapsed
/// this is the prose only; [`App::document`] puts the file back together.
buffer: String,
/// The editorial header lifted out of `buffer` while it is collapsed.
header_stash: Option<String>,
dirty: bool,
/// Editable copy of the workspace path shown in the top bar.
workspace_input: String,
@@ -268,9 +277,33 @@ pub struct App {
spell_rx: Option<std::sync::mpsc::Receiver<SpellCheckMsg>>,
/// One-line status for the spell checker (dictionary name, count, or error).
spell_status: String,
/// Names and invented terms the checker accepts on top of the dictionary,
/// loaded from the workspace's `wordlist.json`.
wordlist: crate::spell::WordList,
/// Whether the word-list window is open.
show_wordlist: bool,
/// When set, the file panel shows only files whose `Status:` matches.
status_filter: Option<String>,
/// The project's cast, read from its character sheets.
characters: Vec<crate::characters::Character>,
/// Whether the Characters window is open.
show_characters: bool,
/// Whether the manuscript-details window is open.
show_manuscript_settings: bool,
/// Whether the Outline window is open.
show_outline: bool,
/// Whether the diff window is open, and what it is showing.
show_diff: bool,
diff_text: String,
diff_title: String,
/// Text field for adding a word by hand.
wordlist_input: String,
/// Index (into the currently displayed matches) of the word a right-click
/// suggestion menu is open for, if any.
spell_menu: Option<usize>,
/// Byte range in `buffer` the editor should select and scroll into view next
/// frame, set when an issue is clicked in the results panel.
issue_jump: Option<(usize, usize)>,
/// Whether the Mistral settings window is open.
show_mistral_settings: bool,
/// Whether the new-file template settings window is open.
@@ -312,12 +345,14 @@ impl App {
workspace_input: config.workspace.display().to_string(),
export_input: config.export_path.display().to_string(),
config,
manuscript_dir: None,
files: Vec::new(),
collapsed: HashSet::new(),
titles: HashMap::new(),
title_input: String::new(),
selected: None,
buffer: String::new(),
header_stash: None,
dirty: false,
new_name: String::new(),
rename_input: String::new(),
@@ -360,7 +395,19 @@ impl App {
spell_last_edit: None,
spell_rx: None,
spell_status: String::new(),
wordlist: crate::spell::WordList::default(),
show_wordlist: false,
status_filter: None,
characters: Vec::new(),
show_characters: false,
show_manuscript_settings: false,
show_outline: false,
show_diff: false,
diff_text: String::new(),
diff_title: String::new(),
wordlist_input: String::new(),
spell_menu: None,
issue_jump: None,
show_mistral_settings: false,
show_template_settings: false,
beats_rx: None,
@@ -471,6 +518,26 @@ impl eframe::App for App {
self.beats_window(ctx);
}
if self.show_wordlist {
self.wordlist_window(ctx);
}
if self.show_characters {
self.characters_window(ctx);
}
if self.show_manuscript_settings {
self.manuscript_settings_window(ctx);
}
if self.show_outline {
self.outline_window(ctx);
}
if self.show_diff {
self.diff_window(ctx);
}
if self.show_cheatsheet {
crate::help::cheatsheet_window(ctx, &mut self.show_cheatsheet);
}