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
+13 -4
View File
@@ -103,7 +103,7 @@ impl App {
} }
/// Get the HEAD commit hash. /// Get the HEAD commit hash.
fn get_head_commit_hash(workspace: &std::path::Path) -> Option<String> { pub(super) fn get_head_commit_hash(workspace: &std::path::Path) -> Option<String> {
let (ok, out) = crate::gitsync::run_git(workspace, &["rev-parse", "HEAD"]); let (ok, out) = crate::gitsync::run_git(workspace, &["rev-parse", "HEAD"]);
if ok { if ok {
out.lines() out.lines()
@@ -115,7 +115,7 @@ impl App {
} }
/// Commit only (no pull/push) - used for snapshots. /// Commit only (no pull/push) - used for snapshots.
fn git_commit_only(workspace: &std::path::Path, message: &str) -> crate::gitsync::GitOutcome { pub(super) fn git_commit_only(workspace: &std::path::Path, message: &str) -> crate::gitsync::GitOutcome {
let mut log = String::new(); let mut log = String::new();
let mut ok = true; let mut ok = true;
@@ -298,11 +298,20 @@ impl App {
ui.label(format!("{} words", snapshot.word_count)); ui.label(format!("{} words", snapshot.word_count));
ui.label(format!("at {}", snapshot.formatted_time())); ui.label(format!("at {}", snapshot.formatted_time()));
ui.label(format!("{}...", &snapshot.message[..snapshot.message.len().min(30)])); ui.label(format!("{}...", &snapshot.message[..snapshot.message.len().min(30)]));
if ui
.button("Diff")
.on_hover_text("Compare the current file against this snapshot")
.clicked()
{
let label = format!("the {} snapshot", snapshot.formatted_time());
self.open_diff_against(Some(snapshot.commit_hash.clone()), label);
}
if ui.button("Rollback").clicked() { if ui.button("Rollback").clicked() {
self.rollback_to_snapshot(orig_index); self.rollback_to_snapshot(orig_index);
} }
// Show commit hash on hover // Show commit hash on hover
if ui.button("ℹ️").on_hover_text(&snapshot.commit_hash).clicked() { if ui.button("ℹ️").on_hover_text(&snapshot.commit_hash).clicked() {
// Could copy to clipboard in the future // Could copy to clipboard in the future
+17 -5
View File
@@ -3,13 +3,24 @@
use super::*; use super::*;
impl App { impl App {
/// Load the diff for the current file, ready for the diff window. /// Load the diff for the current file against the last commit, ready for
/// the diff window.
pub(super) fn open_diff(&mut self) { pub(super) fn open_diff(&mut self) {
self.open_diff_against(None, "the last commit".to_string());
}
/// Load the diff for the current file against `base` (`None` = `HEAD`, the
/// last commit; `Some(hash)` = an older Auto-Snapshot), ready for the diff
/// window. `label` is the human-readable description of `base` shown in
/// the window's header.
pub(super) fn open_diff_against(&mut self, base: Option<String>, label: String) {
self.save_current(); self.save_current();
let Some(name) = self.selected.and_then(|i| self.files.get(i)).cloned() else { let Some(name) = self.selected.and_then(|i| self.files.get(i)).cloned() else {
self.status = "Open a file first".to_string(); self.status = "Open a file first".to_string();
return; return;
}; };
self.diff_base = base.clone();
self.diff_base_label = label.clone();
// Git is run from the work tree, and the workspace may be below it. // Git is run from the work tree, and the workspace may be below it.
let root = self.repo_root.clone(); let root = self.repo_root.clone();
let Some(root) = root else { let Some(root) = root else {
@@ -29,9 +40,10 @@ impl App {
.strip_prefix(&root) .strip_prefix(&root)
.map(|p| p.to_string_lossy().replace('\\', "/")) .map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_else(|_| name.clone()); .unwrap_or_else(|_| name.clone());
self.diff_text = match gitsync::diff_file(&root, &rel) { let base_ref = base.as_deref().unwrap_or("HEAD");
self.diff_text = match gitsync::diff_file_at(&root, &rel, base_ref) {
Ok(text) if text.trim().is_empty() => { Ok(text) if text.trim().is_empty() => {
format!("{name} matches the last commit — no changes.") format!("{name} matches {label} — no changes.")
} }
Ok(text) => text, Ok(text) => text,
Err(e) => format!("Could not diff {name}:\n{e}"), Err(e) => format!("Could not diff {name}:\n{e}"),
@@ -56,7 +68,7 @@ impl App {
refresh = true; refresh = true;
} }
ui.label( ui.label(
egui::RichText::new("compared with the last commit") egui::RichText::new(format!("compared with {}", self.diff_base_label))
.small() .small()
.weak(), .weak(),
); );
@@ -92,7 +104,7 @@ impl App {
}); });
}); });
if refresh { if refresh {
self.open_diff(); self.open_diff_against(self.diff_base.clone(), self.diff_base_label.clone());
} }
self.show_diff = open; self.show_diff = open;
} }
+80 -3
View File
@@ -39,6 +39,13 @@ impl App {
None None
}; };
// Typewriter mode: only while Distraction-free is active (both panels
// hidden — the same condition `toggle_distraction_free` uses), and
// only if the writer hasn't opted out.
let distraction_free = !self.config.show_toolbar && !self.config.show_file_panel;
let typewriter = self.config.typewriter_mode && distraction_free;
let dim_range = typewriter.then(|| current_paragraph_range(&self.buffer, self.cursor_pos));
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.id_salt("editor") .id_salt("editor")
.auto_shrink([false, false]) .auto_shrink([false, false])
@@ -59,6 +66,7 @@ impl App {
&find_ranges, &find_ranges,
find_active, find_active,
&bible_ranges, &bible_ranges,
dim_range,
wrap_width, wrap_width,
); );
ui.fonts(|f| f.layout_job(job)) ui.fonts(|f| f.layout_job(job))
@@ -140,10 +148,16 @@ impl App {
} }
} }
// Whether some explicit jump (an issue pick, a find-match step)
// already scrolled the view this frame — typewriter centering
// stands down when one did, rather than fighting it.
let mut jumped = false;
// Take the editor to an issue picked in the results panel: // Take the editor to an issue picked in the results panel:
// select the words, centre them, and hand focus back so the // select the words, centre them, and hand focus back so the
// user can type the correction straight away. // user can type the correction straight away.
if let Some((start, end)) = self.issue_jump.take() { if let Some((start, end)) = self.issue_jump.take() {
jumped = true;
let (cs, ce) = ( let (cs, ce) = (
byte_to_char(&self.buffer, start), byte_to_char(&self.buffer, start),
byte_to_char(&self.buffer, end), byte_to_char(&self.buffer, end),
@@ -170,6 +184,7 @@ impl App {
// open, Next/Prev, Enter, or after a replace). // open, Next/Prev, Enter, or after a replace).
if self.find_scroll { if self.find_scroll {
if let Some(&(s, _)) = self.find_matches.get(self.find_active) { if let Some(&(s, _)) = self.find_matches.get(self.find_active) {
jumped = true;
let char_idx = self let char_idx = self
.buffer .buffer
.get(..s) .get(..s)
@@ -183,6 +198,25 @@ impl App {
self.find_scroll = false; self.find_scroll = false;
} }
// Typewriter mode: keep the caret's line centered, the way a
// typewriter's platen does. Only re-centers when the cursor
// actually moved since last frame, so scrolling the view by
// hand between keystrokes isn't immediately undone.
let new_cursor_pos = output
.cursor_range
.map(|cr| char_to_byte(&self.buffer, cr.primary.ccursor.index))
.unwrap_or(self.cursor_pos);
if typewriter && !jumped && new_cursor_pos != self.cursor_pos {
if let Some(cr) = output.cursor_range {
let rect = output
.galley
.pos_from_ccursor(cr.primary.ccursor)
.translate(output.galley_pos.to_vec2());
ui.scroll_to_rect(rect, Some(egui::Align::Center));
}
}
self.cursor_pos = new_cursor_pos;
// Right-click a misspelling → a menu of suggested corrections. // Right-click a misspelling → a menu of suggested corrections.
if output.response.secondary_clicked() { if output.response.secondary_clicked() {
self.spell_menu = output.response.interact_pointer_pos().and_then(|pos| { self.spell_menu = output.response.interact_pointer_pos().and_then(|pos| {
@@ -482,6 +516,7 @@ pub(super) fn build_editor_job(
find: &[(usize, usize)], find: &[(usize, usize)],
find_active: Option<usize>, find_active: Option<usize>,
bible: &[(usize, usize)], bible: &[(usize, usize)],
dim: Option<(usize, usize)>,
wrap_width: f32, wrap_width: f32,
) -> egui::text::LayoutJob { ) -> egui::text::LayoutJob {
use egui::text::{LayoutJob, TextFormat}; use egui::text::{LayoutJob, TextFormat};
@@ -489,13 +524,14 @@ pub(super) fn build_editor_job(
let mut job = LayoutJob::default(); let mut job = LayoutJob::default();
job.wrap.max_width = wrap_width; job.wrap.max_width = wrap_width;
if grammar.is_empty() && find.is_empty() && bible.is_empty() { if grammar.is_empty() && find.is_empty() && bible.is_empty() && dim.is_none() {
job.append(text, 0.0, TextFormat::simple(font_id, color)); job.append(text, 0.0, TextFormat::simple(font_id, color));
return job; return job;
} }
// Split the text at every match boundary (grammar, find, and bible), then format // Split the text at every match boundary (grammar, find, bible, and the
// each run according to which ranges cover it. // typewriter-focus paragraph), then format each run according to which
// ranges cover it.
let mut points: Vec<usize> = vec![0, text.len()]; let mut points: Vec<usize> = vec![0, text.len()];
for &(s, e, _) in grammar { for &(s, e, _) in grammar {
points.push(s); points.push(s);
@@ -509,6 +545,10 @@ pub(super) fn build_editor_job(
points.push(s); points.push(s);
points.push(e); points.push(e);
} }
if let Some((s, e)) = dim {
points.push(s);
points.push(e);
}
points.retain(|&p| p <= text.len() && text.is_char_boundary(p)); points.retain(|&p| p <= text.len() && text.is_char_boundary(p));
points.sort_unstable(); points.sort_unstable();
points.dedup(); points.dedup();
@@ -550,6 +590,12 @@ pub(super) fn build_editor_job(
if !is_find_highlight && bible.iter().any(|&(s, e)| s < e && a >= s && b <= e) { if !is_find_highlight && bible.iter().any(|&(s, e)| s < e && a >= s && b <= e) {
fmt.background = bible_bg; fmt.background = bible_bg;
} }
// Typewriter-focus mode: fade every run outside the current paragraph.
if let Some((ds, de)) = dim {
if !(a >= ds && b <= de) {
fmt.color = fmt.color.gamma_multiply(0.35);
}
}
job.append(&text[a..b], 0.0, fmt); job.append(&text[a..b], 0.0, fmt);
} }
job job
@@ -626,6 +672,17 @@ pub(super) fn byte_to_char(s: &str, byte: usize) -> usize {
s.char_indices().take_while(|(b, _)| *b < byte).count() s.char_indices().take_while(|(b, _)| *b < byte).count()
} }
/// The byte range of the paragraph containing `pos`: from just after the
/// nearest blank line (`"\n\n"`) before it to just before the nearest one
/// after it, or the buffer's edges when there is none. Used by typewriter
/// mode to know which paragraph to keep in focus.
pub(super) fn current_paragraph_range(text: &str, pos: usize) -> (usize, usize) {
let pos = pos.min(text.len());
let start = text[..pos].rfind("\n\n").map_or(0, |i| i + 2);
let end = text[pos..].find("\n\n").map_or(text.len(), |i| pos + i);
(start, end)
}
/// Apply an inline-format action to `text` given a selection in *character* /// Apply an inline-format action to `text` given a selection in *character*
/// indices, returning the new text and the new selection (also character indices). /// indices, returning the new text and the new selection (also character indices).
/// ///
@@ -827,6 +884,7 @@ mod tests {
&[], &[],
None, None,
&[(0, 11)], // "Protagonist" &[(0, 11)], // "Protagonist"
None,
f32::INFINITY, f32::INFINITY,
); );
let highlighted = job let highlighted = job
@@ -836,6 +894,25 @@ mod tests {
assert!(highlighted, "a bible-only reference must still be formatted"); assert!(highlighted, "a bible-only reference must still be formatted");
} }
#[test]
fn current_paragraph_range_finds_the_blank_line_boundaries() {
let text = "First paragraph.\n\nSecond paragraph,\nwrapped.\n\nThird.";
// A position in the second paragraph should not reach into the first
// or third.
let pos = text.find("Second").unwrap();
let (s, e) = current_paragraph_range(text, pos);
assert_eq!(&text[s..e], "Second paragraph,\nwrapped.");
// The first paragraph has no blank line before it; the buffer start
// is the boundary.
let (s, e) = current_paragraph_range(text, 0);
assert_eq!(&text[s..e], "First paragraph.");
// The last paragraph has no blank line after it; the buffer end is
// the boundary.
let pos = text.find("Third").unwrap();
let (s, e) = current_paragraph_range(text, pos);
assert_eq!(&text[s..e], "Third.");
}
#[test] #[test]
fn byte_to_char_is_the_inverse_of_char_to_byte() { fn byte_to_char_is_the_inverse_of_char_to_byte() {
// "café!" — é is 2 bytes, so byte and char indices diverge after it. // "café!" — é is 2 bytes, so byte and char indices diverge after it.
+58 -2
View File
@@ -31,9 +31,11 @@ mod spelling;
mod story_bible_editor; mod story_bible_editor;
mod story_bible_ui; mod story_bible_ui;
mod style; mod style;
mod synopsis;
mod ui; mod ui;
mod util; mod util;
mod workspace; mod workspace;
mod workspace_search;
// Pulled up so the sibling modules reach these free helpers through their own // 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. // `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 /// Per-file chapter title overrides (file name -> title). Missing/empty means
/// the title is derived automatically at export time. /// the title is derived automatically at export time.
titles: HashMap<String, String>, 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. /// Editing buffer for the selected file's chapter title override.
title_input: String, title_input: String,
selected: Option<usize>, selected: Option<usize>,
@@ -198,6 +204,11 @@ pub struct App {
/// The editorial header lifted out of `buffer` while it is collapsed. /// The editorial header lifted out of `buffer` while it is collapsed.
header_stash: Option<String>, header_stash: Option<String>,
dirty: bool, 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. /// Editable copy of the workspace path shown in the top bar.
workspace_input: String, workspace_input: String,
/// Editable copy of the export path. /// Editable copy of the export path.
@@ -283,6 +294,19 @@ pub struct App {
find_focus: Option<bool>, find_focus: Option<bool>,
/// Request the editor to scroll the active match into view next frame. /// Request the editor to scroll the active match into view next frame.
find_scroll: bool, 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. /// Active header-field autocomplete popup, if any.
autocomplete: Option<Autocomplete>, autocomplete: Option<Autocomplete>,
/// A partial the user dismissed with Esc; the popup stays closed until the /// A partial the user dismissed with Esc; the popup stays closed until the
@@ -321,6 +345,8 @@ pub struct App {
show_manuscript_settings: bool, show_manuscript_settings: bool,
/// Whether the Outline window is open. /// Whether the Outline window is open.
show_outline: bool, show_outline: bool,
/// Whether the Synopsis window is open.
show_synopsis: bool,
/// Whether to open an outline file from a folder context menu. /// Whether to open an outline file from a folder context menu.
/// Contains the workspace-relative path of the outline file to open. /// Contains the workspace-relative path of the outline file to open.
open_outline_file: Option<String>, open_outline_file: Option<String>,
@@ -328,6 +354,12 @@ pub struct App {
show_diff: bool, show_diff: bool,
diff_text: String, diff_text: String,
diff_title: 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. /// Text field for adding a word by hand.
wordlist_input: String, wordlist_input: String,
/// Index (into the currently displayed matches) of the word a right-click /// Index (into the currently displayed matches) of the word a right-click
@@ -424,11 +456,13 @@ impl App {
files: Vec::new(), files: Vec::new(),
collapsed: HashSet::new(), collapsed: HashSet::new(),
titles: HashMap::new(), titles: HashMap::new(),
word_log: crate::word_log::WordLog::default(),
title_input: String::new(), title_input: String::new(),
selected: None, selected: None,
buffer: String::new(), buffer: String::new(),
header_stash: None, header_stash: None,
dirty: false, dirty: false,
cursor_pos: 0,
new_name: String::new(), new_name: String::new(),
rename_input: String::new(), rename_input: String::new(),
pending_delete: false, pending_delete: false,
@@ -467,6 +501,12 @@ impl App {
find_needs_refresh: false, find_needs_refresh: false,
find_focus: None, find_focus: None,
find_scroll: false, 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, autocomplete: None,
ac_dismissed: None, ac_dismissed: None,
field_names: Vec::new(), field_names: Vec::new(),
@@ -485,10 +525,13 @@ impl App {
show_characters: false, show_characters: false,
show_manuscript_settings: false, show_manuscript_settings: false,
show_outline: false, show_outline: false,
show_synopsis: false,
open_outline_file: None, open_outline_file: None,
show_diff: false, show_diff: false,
diff_text: String::new(), diff_text: String::new(),
diff_title: String::new(), diff_title: String::new(),
diff_base: None,
diff_base_label: "the last commit".to_string(),
wordlist_input: String::new(), wordlist_input: String::new(),
spell_menu: None, spell_menu: None,
dismissed: HashSet::new(), dismissed: HashSet::new(),
@@ -555,10 +598,16 @@ impl eframe::App for App {
self.toggle_distraction_free(); self.toggle_distraction_free();
} }
// Ctrl+F opens find (search focused); Ctrl+H opens it focused on replace. // Ctrl+F opens find (search focused); Ctrl+Shift+F searches the whole
if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::F)) { // 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); 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)) { if ctx.input(|i| i.modifiers.command && i.key_pressed(egui::Key::H)) {
self.open_find(false); self.open_find(false);
} }
@@ -676,6 +725,13 @@ impl eframe::App for App {
if self.show_outline { if self.show_outline {
self.outline_window(ctx); 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 { if self.show_diff {
self.diff_window(ctx); self.diff_window(ctx);
+91
View File
@@ -0,0 +1,91 @@
//! A single-glance list of every chapter's synopsis (its `# Slug:` line), read
//! straight from the same cached metadata the file-list tooltips use, so a
//! revision pass can scan the whole manuscript's shape without opening each
//! file in turn.
use super::*;
impl App {
/// The synopsis window: one row per manuscript file, in export order.
pub(super) fn synopsis_window(&mut self, ctx: &egui::Context) {
let mut open = self.show_synopsis;
let mut jump_to: Option<String> = None;
// Collect everything the window needs up front, so the window's
// closure only reads — no borrow conflicts with the deferred jump.
let marker = self.config.draft_marker.clone();
let pad_width = self.index_pad_width();
let selected_name = self.selected.and_then(|i| self.files.get(i)).cloned();
let rows: Vec<(String, String, Option<String>)> = self
.manuscript_files()
.into_iter()
.map(|(i, name)| {
let raw = if Some(name) == selected_name.as_ref() {
self.document().into_owned()
} else {
std::fs::read_to_string(self.path_for(name)).unwrap_or_default()
};
let header = crate::preprocess::parse(&raw, &marker);
let title = resolve_chapter_title(
self.titles.get(name).map(String::as_str),
header.title.as_deref(),
i,
pad_width,
);
(name.clone(), title, header.slug)
})
.collect();
egui::Window::new("Synopsis")
.open(&mut open)
.resizable(true)
.collapsible(false)
.default_width(520.0)
.default_height(480.0)
.show(ctx, |ui| {
ui.label(
egui::RichText::new(
"Every chapter's # Slug: line, in manuscript order. Click a \
title to open it.",
)
.small()
.weak(),
);
ui.separator();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
if rows.is_empty() {
ui.label("No manuscript files yet.");
}
for (name, title, slug) in &rows {
ui.horizontal_wrapped(|ui| {
if ui.link(egui::RichText::new(title).strong()).clicked() {
jump_to = Some(name.clone());
}
match slug.as_deref().map(str::trim) {
Some(s) if !s.is_empty() => {
ui.label(s);
}
_ => {
ui.label(
egui::RichText::new("— no synopsis —")
.weak()
.italics(),
);
}
}
});
ui.separator();
}
});
});
if let Some(name) = jump_to {
if let Some(idx) = self.files.iter().position(|f| *f == name) {
self.select(idx);
}
}
self.show_synopsis = open;
}
}
+34
View File
@@ -136,6 +136,17 @@ impl App {
files are not counted)", files are not counted)",
); );
// A streak of consecutive days with words added,
// persisted in word_log.json so it survives restarts.
let streak = self.word_log.streak(crate::word_log::today());
if streak > 1 {
ui.separator();
ui.label(egui::RichText::new(format!("🔥 {streak}-day streak")).weak())
.on_hover_text(
"Consecutive days the manuscript's word count has grown",
);
}
// Progress toward the file's Word Count Target, if set. // Progress toward the file's Word Count Target, if set.
if let Some((goal, prose)) = goal { if let Some((goal, prose)) = goal {
let (frac, color, text) = goal_progress(goal, prose); let (frac, color, text) = goal_progress(goal, prose);
@@ -238,6 +249,14 @@ impl App {
ui.close_menu(); ui.close_menu();
self.open_find(true); self.open_find(true);
} }
if ui
.button("🔎 Search workspace… Ctrl+Shift+F")
.on_hover_text("Find (and optionally replace) across every file")
.clicked()
{
ui.close_menu();
self.open_workspace_search();
}
if ui.button("📸 Snapshots…").clicked() { if ui.button("📸 Snapshots…").clicked() {
ui.close_menu(); ui.close_menu();
self.show_snapshots = true; self.show_snapshots = true;
@@ -260,6 +279,10 @@ impl App {
ui.close_menu(); ui.close_menu();
self.open_story_bible_editor(); self.open_story_bible_editor();
} }
if ui.button("🧾 Synopsis…").clicked() {
ui.close_menu();
self.show_synopsis = true;
}
ui.separator(); ui.separator();
let busy = self.beats_rx.is_some(); let busy = self.beats_rx.is_some();
if ui if ui
@@ -302,6 +325,17 @@ impl App {
ui.close_menu(); ui.close_menu();
self.toggle_distraction_free(); self.toggle_distraction_free();
} }
if ui
.checkbox(&mut self.config.typewriter_mode, "Typewriter scrolling & focus")
.on_hover_text(
"While Distraction-free mode is on, keep the caret's line \
centered and dim every paragraph but the current one. \
Has no effect otherwise.",
)
.changed()
{
self.config.save();
}
ui.separator(); ui.separator();
if ui if ui
.checkbox(&mut self.config.show_preview, "Preview pane") .checkbox(&mut self.config.show_preview, "Preview pane")
+7
View File
@@ -78,6 +78,7 @@ impl App {
self.manuscript_dir = self.detect_manuscript_dir(&ws); self.manuscript_dir = self.detect_manuscript_dir(&ws);
self.files = order::resolve_order(&ws, &self.config.hidden_folders); self.files = order::resolve_order(&ws, &self.config.hidden_folders);
self.titles = order::read_titles(&ws); self.titles = order::read_titles(&ws);
self.word_log = crate::word_log::WordLog::load(&ws);
self.wordlist = crate::spell::read_wordlist(&ws); self.wordlist = crate::spell::read_wordlist(&ws);
// Drop overrides for files that no longer exist. // Drop overrides for files that no longer exist.
self.titles.retain(|name, _| self.files.contains(name)); self.titles.retain(|name, _| self.files.contains(name));
@@ -546,6 +547,12 @@ impl App {
); );
self.file_meta.insert(name, meta); self.file_meta.insert(name, meta);
self.merge_field_names_from_buffer(); self.merge_field_names_from_buffer();
// Record today's running total for the writing
// streak. Cheap: a handful of small JSON writes,
// no more often than the manuscript itself is saved.
let total = self.manuscript_words();
self.word_log.record(crate::word_log::today(), total);
self.word_log.save(self.workspace());
} }
Err(e) => self.status = format!("Save failed: {e}"), Err(e) => self.status = format!("Save failed: {e}"),
} }
+257
View File
@@ -0,0 +1,257 @@
//! Search and replace across every file in the workspace, not just the one
//! open in the editor. Reuses the matching/replacing helpers from `find.rs`
//! rather than reimplementing them.
use super::*;
use super::find::{find_matches, replace_all};
impl App {
/// Open the workspace search window and run the current query, if any.
pub(super) fn open_workspace_search(&mut self) {
self.show_workspace_search = true;
self.refresh_workspace_search();
}
/// Re-run the workspace-wide search: every file in `self.files` is checked
/// (the currently open one via its in-memory buffer, in case it has
/// unsaved edits; every other one is read fresh from disk).
pub(super) fn refresh_workspace_search(&mut self) {
self.ws_results.clear();
if self.ws_query.trim().is_empty() {
self.ws_status = String::new();
return;
}
let selected_name = self.selected.and_then(|i| self.files.get(i)).cloned();
let mut total = 0usize;
for name in self.files.clone() {
let text = if Some(&name) == selected_name.as_ref() {
self.document().into_owned()
} else {
match std::fs::read_to_string(self.path_for(&name)) {
Ok(t) => t,
Err(_) => continue,
}
};
let matches = find_matches(&text, &self.ws_query, self.ws_case_sensitive);
if matches.is_empty() {
continue;
}
total += matches.len();
let snippets = matches
.iter()
.take(3)
.map(|&(s, e)| line_snippet(&text, s, e))
.collect();
self.ws_results.push((name, matches.len(), snippets));
}
self.ws_status = if self.ws_results.is_empty() {
"No matches".to_string()
} else {
format!(
"{total} match{} in {} file{}",
if total == 1 { "" } else { "es" },
self.ws_results.len(),
if self.ws_results.len() == 1 { "" } else { "s" }
)
};
}
/// Open `name` and hand off to the ordinary find bar, so stepping through
/// the rest of that file's matches works exactly like Ctrl+F already does.
/// The match position is recomputed against the freshly loaded buffer
/// rather than reusing a byte offset from the search pass, since opening
/// the file can fold its header away and shift every offset within it.
fn jump_to(&mut self, name: &str) {
let Some(idx) = self.files.iter().position(|f| f == name) else {
return;
};
self.select(idx);
self.find_query = self.ws_query.clone();
self.find_case_sensitive = self.ws_case_sensitive;
self.find_active = 0;
self.refresh_find();
self.show_find = true;
self.find_scroll = true;
}
/// Replace every match of `ws_query` with `ws_replace`, in every file the
/// last search found it in. Takes a safety commit first (if the workspace
/// is a git repository) so a bad bulk replace is one `git revert`/checkout
/// away from undone, regardless of whether Auto-Snapshot is enabled.
pub(super) fn workspace_replace_all(&mut self) {
if self.ws_query.is_empty() || self.ws_results.is_empty() {
return;
}
if let Some(root) = self.repo_root.clone() {
self.save_current();
self.persist_order();
self.persist_titles();
let message = format!(
"Snapshot before workspace replace: '{}' -> '{}'",
self.ws_query, self.ws_replace
);
let outcome = Self::git_commit_only(&root, &message);
if !outcome.ok {
self.status = format!(
"Safety snapshot failed, replace cancelled: {}",
outcome.log
);
return;
}
} else {
self.status =
"Not in a git repository — replacing without a safety snapshot".to_string();
}
let selected_name = self.selected.and_then(|i| self.files.get(i)).cloned();
let names: Vec<String> = self.ws_results.iter().map(|(n, _, _)| n.clone()).collect();
let mut total = 0usize;
let mut files_changed = 0usize;
for name in names {
let is_open = Some(&name) == selected_name.as_ref();
let text = if is_open {
self.document().into_owned()
} else {
match std::fs::read_to_string(self.path_for(&name)) {
Ok(t) => t,
Err(_) => continue,
}
};
let (new_text, count) =
replace_all(&text, &self.ws_query, &self.ws_replace, self.ws_case_sensitive);
if count == 0 {
continue;
}
total += count;
files_changed += 1;
if is_open {
// `new_text` is the whole document (header included, if any
// was folded); fold it back the same way it was before.
self.header_stash = None;
self.buffer = new_text;
self.dirty = true;
self.apply_header_collapse();
self.clear_lt();
self.save_current();
self.find_needs_refresh = true;
} else if let Err(e) = std::fs::write(self.path_for(&name), &new_text) {
self.status = format!("Failed to write {name}: {e}");
} else {
let meta = FileMeta::from_markdown(&new_text, &self.config.draft_marker);
self.file_meta.insert(name, meta);
}
}
self.status =
format!("Replaced {total} occurrence(s) across {files_changed} file(s)");
self.refresh_workspace_search();
}
/// The workspace search window.
pub(super) fn workspace_search_window(&mut self, ctx: &egui::Context) {
let mut open = self.show_workspace_search;
let mut jump: Option<String> = None;
let mut do_search = false;
let mut do_replace = false;
egui::Window::new("Search workspace")
.open(&mut open)
.resizable(true)
.collapsible(false)
.default_width(560.0)
.default_height(480.0)
.show(ctx, |ui| {
ui.horizontal(|ui| {
ui.label("Find:");
let resp = ui.add(
egui::TextEdit::singleline(&mut self.ws_query)
.desired_width(240.0)
.hint_text("search every file"),
);
if resp.changed() || resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
do_search = true;
}
if ui
.checkbox(&mut self.ws_case_sensitive, "Aa")
.on_hover_text("Match case")
.changed()
{
do_search = true;
}
if ui.button("Search").clicked() {
do_search = true;
}
});
ui.horizontal(|ui| {
ui.label("Replace:");
ui.add(
egui::TextEdit::singleline(&mut self.ws_replace)
.desired_width(240.0)
.hint_text("replacement text"),
);
if ui
.add_enabled(!self.ws_results.is_empty(), egui::Button::new("Replace all in workspace"))
.on_hover_text(
"Replaces every match in every file listed below. Takes a \
safety git commit first, if this workspace is a repository.",
)
.clicked()
{
do_replace = true;
}
});
if !self.ws_status.is_empty() {
ui.label(egui::RichText::new(&self.ws_status).weak());
}
ui.separator();
egui::ScrollArea::vertical()
.auto_shrink([false, false])
.show(ui, |ui| {
for (name, count, snippets) in &self.ws_results {
ui.horizontal(|ui| {
if ui.link(egui::RichText::new(name).strong()).clicked() {
jump = Some(name.clone());
}
ui.label(
egui::RichText::new(format!(
"({count} match{})",
if *count == 1 { "" } else { "es" }
))
.weak(),
);
});
for snippet in snippets {
ui.label(egui::RichText::new(snippet).monospace().small());
}
ui.separator();
}
});
});
if do_search {
self.refresh_workspace_search();
}
if do_replace {
self.workspace_replace_all();
}
if let Some(name) = jump {
self.jump_to(&name);
}
self.show_workspace_search = open;
}
}
/// A short, trimmed excerpt of the line containing the byte range `s..e`, for
/// display in the workspace search results list.
fn line_snippet(text: &str, s: usize, e: usize) -> String {
let line_start = text[..s].rfind('\n').map_or(0, |i| i + 1);
let line_end = text[e..].find('\n').map_or(text.len(), |i| e + i);
let line = text[line_start..line_end].trim();
const MAX: usize = 120;
if line.chars().count() > MAX {
let truncated: String = line.chars().take(MAX).collect();
format!("{truncated}…")
} else {
line.to_string()
}
}
+43
View File
@@ -169,6 +169,18 @@ pub struct Config {
/// Word count threshold for auto-snapshots. /// Word count threshold for auto-snapshots.
#[serde(default = "default_auto_snapshot_threshold")] #[serde(default = "default_auto_snapshot_threshold")]
pub auto_snapshot_threshold: usize, pub auto_snapshot_threshold: usize,
/// Whether Distraction-free mode also keeps the caret's line centered and
/// dims every paragraph but the current one. Has no effect while
/// Distraction-free mode is off; this is the opt-out for writers who
/// don't want it even then.
#[serde(default = "default_typewriter_mode")]
pub typewriter_mode: bool,
}
/// Typewriter mode is on by default, for anyone turning on Distraction-free
/// mode for the first time.
pub fn default_typewriter_mode() -> bool {
true
} }
/// Default auto-snapshot threshold: 250 words. /// Default auto-snapshot threshold: 250 words.
@@ -371,6 +383,7 @@ impl Default for Config {
manuscript_screenplay: false, manuscript_screenplay: false,
auto_snapshot_enabled: false, auto_snapshot_enabled: false,
auto_snapshot_threshold: default_auto_snapshot_threshold(), auto_snapshot_threshold: default_auto_snapshot_threshold(),
typewriter_mode: default_typewriter_mode(),
} }
} }
} }
@@ -597,6 +610,36 @@ mod tests {
assert!(!cfg.manuscript_screenplay); assert!(!cfg.manuscript_screenplay);
} }
/// A config.json written before typewriter mode existed. Same risk as the
/// tests above: a missing `#[serde(default)]` would fail the whole parse
/// and reset every setting the user had.
#[test]
fn config_without_the_typewriter_mode_field_still_loads() {
let old = r####"{
"workspace": "/home/writer/Manuscript",
"export_path": "/home/writer/Manuscript/book.odt",
"manuscript_author": "A. Writer"
}"####;
let cfg: Config = serde_json::from_str(old).expect("old config must still parse");
assert_eq!(cfg.manuscript_author, "A. Writer");
// On by default, so an existing Distraction-free habit gains the
// feature rather than needing to discover and enable it.
assert!(cfg.typewriter_mode);
}
/// An explicit opt-out must survive a reload, the same way `gitea_use_ssh`
/// being turned off has to (both default to `true`).
#[test]
fn turning_off_typewriter_mode_survives_a_reload() {
let saved = r####"{
"workspace": "/home/writer/Manuscript",
"export_path": "/home/writer/Manuscript/book.odt",
"typewriter_mode": false
}"####;
let cfg: Config = serde_json::from_str(saved).expect("config must parse");
assert!(!cfg.typewriter_mode);
}
/// A saved subfolder choice must win over the built-in default, so changing /// A saved subfolder choice must win over the built-in default, so changing
/// the default never silently moves an existing user's projects. /// the default never silently moves an existing user's projects.
#[test] #[test]
+4 -3
View File
@@ -192,7 +192,8 @@ fn current_branch(workspace: &Path) -> String {
.unwrap_or_else(|| "main".to_string()) .unwrap_or_else(|| "main".to_string())
} }
/// The unified diff of one file against the last commit, as raw diff text. /// The unified diff of one file against an arbitrary commit-ish (`HEAD`, a
/// commit hash, a tag...) — the last commit, or an older Auto-Snapshot.
/// ///
/// Revising is comparing: what changed in this scene since the last time it was /// Revising is comparing: what changed in this scene since the last time it was
/// good? The repository already holds that answer, so the app asks git rather /// good? The repository already holds that answer, so the app asks git rather
@@ -200,10 +201,10 @@ fn current_branch(workspace: &Path) -> String {
/// ///
/// `rel` is relative to the *workspace*, which may sit below the repository /// `rel` is relative to the *workspace*, which may sit below the repository
/// root, so the path is resolved through the work tree before being handed over. /// root, so the path is resolved through the work tree before being handed over.
pub fn diff_file(workspace: &Path, rel: &str) -> Result<String, String> { pub fn diff_file_at(workspace: &Path, rel: &str, base: &str) -> Result<String, String> {
let (ok, out) = run_git( let (ok, out) = run_git(
workspace, workspace,
&["--no-pager", "diff", "--no-color", "HEAD", "--", rel], &["--no-pager", "diff", "--no-color", base, "--", rel],
); );
if !ok { if !ok {
// A repository with no commits yet has no HEAD to compare against. // A repository with no commits yet has no HEAD to compare against.
+1
View File
@@ -20,6 +20,7 @@ mod project_template;
mod screenplay; mod screenplay;
mod spell; mod spell;
mod story_bible; mod story_bible;
mod word_log;
use eframe::egui; use eframe::egui;
+176
View File
@@ -0,0 +1,176 @@
//! A per-day log of the manuscript's total word count, kept in the workspace
//! (like `order.json`/`titles.json`) so writing progress — and a streak —
//! survives between launches and syncs with git for free.
use serde::{Deserialize, Serialize};
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
/// Name of the file (inside the workspace) that stores the word log.
pub const WORD_LOG_FILE: &str = "word_log.json";
/// One calendar day's recorded state: the manuscript's total prose word
/// count as of the last save made that day.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DayEntry {
/// Days since the Unix epoch (UTC) — so entries compare and subtract
/// without any calendar arithmetic.
pub day: i64,
/// Total prose word count across the manuscript as of the last save on
/// this day.
pub total_words: usize,
}
/// The word log: one entry per day that had at least one save, oldest first.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct WordLog {
pub days: Vec<DayEntry>,
}
/// Today, as days since the Unix epoch (UTC).
pub fn today() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| (d.as_secs() / 86_400) as i64)
.unwrap_or(0)
}
impl WordLog {
/// Load the log from the workspace. Missing or unparsable -> empty log,
/// the same tolerant fallback `Config::load` uses — a corrupt or
/// not-yet-existing log must never block opening the workspace.
pub fn load(workspace: &Path) -> WordLog {
let path = workspace.join(WORD_LOG_FILE);
match std::fs::read_to_string(&path) {
Ok(text) => serde_json::from_str(&text).unwrap_or_default(),
Err(_) => WordLog::default(),
}
}
/// Persist the log as JSON into the workspace.
pub fn save(&self, workspace: &Path) {
let path = workspace.join(WORD_LOG_FILE);
if let Ok(text) = serde_json::to_string_pretty(self) {
let _ = std::fs::write(path, text);
}
}
/// Record `total_words` as `day`'s state, replacing any earlier record
/// for the same day — later saves on a day simply overwrite it, so the
/// log only ever holds each day's final total.
pub fn record(&mut self, day: i64, total_words: usize) {
if let Some(entry) = self.days.iter_mut().find(|d| d.day == day) {
entry.total_words = total_words;
} else {
self.days.push(DayEntry { day, total_words });
self.days.sort_by_key(|d| d.day);
}
}
/// Whether `day` grew the manuscript: its recorded total is higher than
/// the most recent *earlier* recorded total (whichever day that was —
/// not necessarily `day - 1`; a missing day before it defaults to a
/// baseline of 0, so the very first entry ever logged counts as long as
/// it has any words at all).
fn wrote(&self, day: i64) -> bool {
let Some(today_total) = self.days.iter().find(|d| d.day == day).map(|d| d.total_words)
else {
return false;
};
let prev_total = self
.days
.iter()
.filter(|d| d.day < day)
.map(|d| d.total_words)
.max()
.unwrap_or(0);
today_total > prev_total
}
/// The current writing streak as of `today`: consecutive calendar days,
/// ending today (or yesterday, if nothing has been written yet today),
/// each of which grew the manuscript. A missing day breaks the chain —
/// today itself still counts if it grew the manuscript, but the streak
/// doesn't reach back past the gap.
pub fn streak(&self, today: i64) -> usize {
let mut day = if self.days.iter().any(|d| d.day == today) {
today
} else {
today - 1
};
let mut count = 0usize;
loop {
if !self.wrote(day) {
break;
}
count += 1;
if !self.days.iter().any(|d| d.day == day - 1) {
break;
}
day -= 1;
}
count
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn streak_counts_consecutive_growing_days() {
let mut log = WordLog::default();
log.record(10, 1000);
log.record(11, 1500);
log.record(12, 1800);
assert_eq!(log.streak(12), 3);
// Nothing written yet today (13) — still counts through yesterday.
assert_eq!(log.streak(13), 3);
}
#[test]
fn a_gap_day_breaks_the_streak() {
let mut log = WordLog::default();
log.record(10, 1000);
log.record(11, 1500);
// 12 is skipped entirely.
log.record(13, 1800);
assert_eq!(log.streak(13), 1);
}
#[test]
fn a_flat_or_shrinking_day_breaks_the_streak() {
let mut log = WordLog::default();
log.record(10, 1000);
log.record(11, 1000); // no change
log.record(12, 1400);
assert_eq!(log.streak(12), 1);
}
#[test]
fn the_very_first_day_counts_from_a_zero_baseline() {
let mut log = WordLog::default();
log.record(10, 500);
assert_eq!(log.streak(10), 1);
assert_eq!(log.streak(11), 1);
}
#[test]
fn later_saves_the_same_day_overwrite_rather_than_duplicate() {
let mut log = WordLog::default();
log.record(10, 500);
log.record(10, 900);
assert_eq!(log.days.len(), 1);
assert_eq!(log.days[0].total_words, 900);
}
#[test]
fn load_of_a_missing_file_is_an_empty_log_not_an_error() {
let dir = std::env::temp_dir().join(format!(
"md-manuscript-wordlog-test-{}",
std::process::id()
));
let log = WordLog::load(&dir);
assert!(log.days.is_empty());
}
}