Add workspace search, synopsis view, snapshot diffs, typewriter mode, and a writing streak

Five productivity features: search/replace across the whole workspace
(with a safety commit before Replace all), a Synopsis window compiling
every chapter's slug, a Diff button per Auto-Snapshot to compare against
an older snapshot instead of only HEAD, typewriter-style cursor centering
and paragraph dimming gated on Distraction-free mode, and a per-day
word-count streak persisted to word_log.json alongside order.json/titles.json.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 15:55:15 -05:00
parent 229e2413c4
commit 6ca8681d08
12 changed files with 781 additions and 17 deletions
+58 -2
View File
@@ -31,9 +31,11 @@ mod spelling;
mod story_bible_editor;
mod story_bible_ui;
mod style;
mod synopsis;
mod ui;
mod util;
mod workspace;
mod workspace_search;
// Pulled up so the sibling modules reach these free helpers through their own
// `use super::*;`. Modules whose helpers are only used locally aren't listed.
@@ -189,6 +191,10 @@ pub struct App {
/// Per-file chapter title overrides (file name -> title). Missing/empty means
/// the title is derived automatically at export time.
titles: HashMap<String, String>,
/// Per-day manuscript word-count log, for the writing streak in the
/// status bar. Lives in the workspace (`word_log.json`) and syncs with
/// git the same way `titles`/`order.json` do.
word_log: crate::word_log::WordLog,
/// Editing buffer for the selected file's chapter title override.
title_input: String,
selected: Option<usize>,
@@ -198,6 +204,11 @@ pub struct App {
/// The editorial header lifted out of `buffer` while it is collapsed.
header_stash: Option<String>,
dirty: bool,
/// Byte offset of the primary cursor in `buffer`, as of the last frame.
/// Used by typewriter mode to notice the cursor moved (and re-center it)
/// versus the writer scrolling the view manually, and to find the current
/// paragraph to keep in focus.
cursor_pos: usize,
/// Editable copy of the workspace path shown in the top bar.
workspace_input: String,
/// Editable copy of the export path.
@@ -283,6 +294,19 @@ pub struct App {
find_focus: Option<bool>,
/// Request the editor to scroll the active match into view next frame.
find_scroll: bool,
/// Whether the workspace-wide search window is visible.
show_workspace_search: bool,
/// The workspace search text.
ws_query: String,
/// The workspace replacement text.
ws_replace: String,
/// Whether the workspace search is case-sensitive.
ws_case_sensitive: bool,
/// The last workspace search's results: one entry per file with at least
/// one match — its name, total match count, and a few line snippets.
ws_results: Vec<(String, usize, Vec<String>)>,
/// One-line status for the workspace search window.
ws_status: String,
/// Active header-field autocomplete popup, if any.
autocomplete: Option<Autocomplete>,
/// A partial the user dismissed with Esc; the popup stays closed until the
@@ -321,6 +345,8 @@ pub struct App {
show_manuscript_settings: bool,
/// Whether the Outline window is open.
show_outline: bool,
/// Whether the Synopsis window is open.
show_synopsis: bool,
/// Whether to open an outline file from a folder context menu.
/// Contains the workspace-relative path of the outline file to open.
open_outline_file: Option<String>,
@@ -328,6 +354,12 @@ pub struct App {
show_diff: bool,
diff_text: String,
diff_title: String,
/// Commit the diff window is comparing against. `None` means the last
/// commit (`HEAD`); `Some(hash)` means an older Auto-Snapshot picked from
/// the Snapshots window.
diff_base: Option<String>,
/// Human-readable label for `diff_base`, shown in the diff window's header.
diff_base_label: String,
/// Text field for adding a word by hand.
wordlist_input: String,
/// Index (into the currently displayed matches) of the word a right-click
@@ -424,11 +456,13 @@ impl App {
files: Vec::new(),
collapsed: HashSet::new(),
titles: HashMap::new(),
word_log: crate::word_log::WordLog::default(),
title_input: String::new(),
selected: None,
buffer: String::new(),
header_stash: None,
dirty: false,
cursor_pos: 0,
new_name: String::new(),
rename_input: String::new(),
pending_delete: false,
@@ -467,6 +501,12 @@ impl App {
find_needs_refresh: false,
find_focus: None,
find_scroll: false,
show_workspace_search: false,
ws_query: String::new(),
ws_replace: String::new(),
ws_case_sensitive: false,
ws_results: Vec::new(),
ws_status: String::new(),
autocomplete: None,
ac_dismissed: None,
field_names: Vec::new(),
@@ -485,10 +525,13 @@ impl App {
show_characters: false,
show_manuscript_settings: false,
show_outline: false,
show_synopsis: false,
open_outline_file: None,
show_diff: false,
diff_text: String::new(),
diff_title: String::new(),
diff_base: None,
diff_base_label: "the last commit".to_string(),
wordlist_input: String::new(),
spell_menu: None,
dismissed: HashSet::new(),
@@ -555,10 +598,16 @@ impl eframe::App for App {
self.toggle_distraction_free();
}
// Ctrl+F opens find (search focused); Ctrl+H opens it focused on replace.
if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::F)) {
// Ctrl+F opens find (search focused); Ctrl+Shift+F searches the whole
// workspace instead; Ctrl+H opens find focused on replace.
if ctx.input(|i| i.modifiers.command && !i.modifiers.shift && i.key_pressed(egui::Key::F))
{
self.open_find(true);
}
if ctx.input(|i| i.modifiers.command && i.modifiers.shift && i.key_pressed(egui::Key::F))
{
self.open_workspace_search();
}
if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::H)) {
self.open_find(false);
}
@@ -676,6 +725,13 @@ impl eframe::App for App {
if self.show_outline {
self.outline_window(ctx);
}
if self.show_synopsis {
self.synopsis_window(ctx);
}
if self.show_workspace_search {
self.workspace_search_window(ctx);
}
if self.show_diff {
self.diff_window(ctx);