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
+99
View File
@@ -0,0 +1,99 @@
//! Comparing the open file with its last committed version.
use super::*;
impl App {
/// Load the diff for the current file, ready for the diff window.
pub(super) fn open_diff(&mut self) {
self.save_current();
let Some(name) = self.selected.and_then(|i| self.files.get(i)).cloned() else {
self.status = "Open a file first".to_string();
return;
};
// Git is run from the work tree, and the workspace may be below it.
let root = self.repo_root.clone();
let Some(root) = root else {
self.diff_text = "This workspace is not in a git repository.".to_string();
self.show_diff = true;
return;
};
if !gitsync::has_commits(&root) {
self.diff_text = "The repository has no commits yet, so there is \
nothing to compare against."
.to_string();
self.show_diff = true;
return;
}
let abs = self.path_for(&name);
let rel = abs
.strip_prefix(&root)
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_else(|_| name.clone());
self.diff_text = match gitsync::diff_file(&root, &rel) {
Ok(text) if text.trim().is_empty() => {
format!("{name} matches the last commit — no changes.")
}
Ok(text) => text,
Err(e) => format!("Could not diff {name}:\n{e}"),
};
self.diff_title = name;
self.show_diff = true;
}
/// The diff window: added and removed lines since the last commit.
pub(super) fn diff_window(&mut self, ctx: &egui::Context) {
let mut open = self.show_diff;
let mut refresh = false;
egui::Window::new(format!("Changes — {}", self.diff_title))
.open(&mut open)
.resizable(true)
.collapsible(false)
.default_width(680.0)
.default_height(460.0)
.show(ctx, |ui| {
ui.horizontal(|ui| {
if ui.button("⟳ Refresh").clicked() {
refresh = true;
}
ui.label(
egui::RichText::new("compared with the last commit")
.small()
.weak(),
);
});
ui.separator();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
for line in self.diff_text.lines() {
// Colour the diff the way git does, so additions and
// removals are separable at a glance.
let (color, strong) = match line.as_bytes().first() {
Some(b'+') if !line.starts_with("+++") => {
(Some(egui::Color32::from_rgb(0x3F, 0x9E, 0x4F)), false)
}
Some(b'-') if !line.starts_with("---") => {
(Some(egui::Color32::from_rgb(0xC0, 0x50, 0x50)), false)
}
Some(b'@') => {
(Some(egui::Color32::from_rgb(0x3B, 0x82, 0xF6)), true)
}
_ => (None, false),
};
let mut text = egui::RichText::new(line).monospace();
if let Some(color) = color {
text = text.color(color);
}
if strong {
text = text.strong();
}
ui.label(text);
}
});
});
if refresh {
self.open_diff();
}
self.show_diff = open;
}
}