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
+41
View File
@@ -11,6 +11,14 @@ pub struct Config {
/// Whether to show the live preview pane.
#[serde(default)]
pub show_preview: bool,
/// Whether the editor hides the editorial header, showing the prose alone.
#[serde(default)]
pub collapse_header: bool,
/// Whether the file panel lists a project's reference files (characters,
/// outline, scratch pad) alongside the manuscript. Off by default: the panel
/// is for the book, and the reference material has windows of its own.
#[serde(default)]
pub show_reference_files: bool,
/// Marker line separating a file's editorial header from its prose. Content
/// above it (except `# Title:` / `# Slug:` metadata) is dropped on export.
/// An empty value disables header splitting.
@@ -103,6 +111,22 @@ pub struct Config {
/// like the Mistral key.
#[serde(default)]
pub gitea_token: String,
/// Folders kept out of the file panel entirely, matched leniently so
/// `Archive` covers `10-Archive`. An archive of dead drafts can hold
/// hundreds of files that would otherwise swamp the tree.
#[serde(default = "default_hidden_folders")]
pub hidden_folders: Vec<String>,
/// Folder the Archive action moves files into. Matched leniently against
/// what the project already has, so `10-Archive` is found and reused.
#[serde(default = "default_archive_folder")]
pub archive_folder: String,
/// Manuscript title written into exported documents. Blank falls back to the
/// project folder's name.
#[serde(default)]
pub manuscript_title: String,
/// Author written into exported documents.
#[serde(default)]
pub manuscript_author: String,
}
/// Default cookiecutter template for **File ▸ New project…**.
@@ -126,6 +150,17 @@ pub fn default_project_run_hooks() -> bool {
true
}
/// Folders excluded from the panel by default: the snowflake layout's archive,
/// which holds superseded drafts rather than working material.
pub fn default_hidden_folders() -> Vec<String> {
vec!["Archive".to_string()]
}
/// Default archive folder, matching the snowflake layout.
pub fn default_archive_folder() -> String {
"10-Archive".to_string()
}
/// Default parent directory for new projects.
pub fn default_projects_dir() -> PathBuf {
dirs::document_dir().unwrap_or_else(|| {
@@ -202,6 +237,8 @@ impl Default for Config {
workspace,
export_path,
show_preview: false,
collapse_header: false,
show_reference_files: false,
draft_marker: default_marker(),
zero_pad_index: false,
editor_zoom: 0.0,
@@ -226,6 +263,10 @@ impl Default for Config {
gitea_url: String::new(),
gitea_user: String::new(),
gitea_token: String::new(),
hidden_folders: default_hidden_folders(),
archive_folder: default_archive_folder(),
manuscript_title: String::new(),
manuscript_author: String::new(),
}
}
}