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:
@@ -103,7 +103,7 @@ impl App {
|
||||
}
|
||||
|
||||
/// 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"]);
|
||||
if ok {
|
||||
out.lines()
|
||||
@@ -115,7 +115,7 @@ impl App {
|
||||
}
|
||||
|
||||
/// 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 ok = true;
|
||||
|
||||
@@ -298,11 +298,20 @@ impl App {
|
||||
ui.label(format!("{} words", snapshot.word_count));
|
||||
ui.label(format!("at {}", snapshot.formatted_time()));
|
||||
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() {
|
||||
self.rollback_to_snapshot(orig_index);
|
||||
}
|
||||
|
||||
|
||||
// Show commit hash on hover
|
||||
if ui.button("ℹ️").on_hover_text(&snapshot.commit_hash).clicked() {
|
||||
// Could copy to clipboard in the future
|
||||
|
||||
+17
-5
@@ -3,13 +3,24 @@
|
||||
use super::*;
|
||||
|
||||
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) {
|
||||
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();
|
||||
let Some(name) = self.selected.and_then(|i| self.files.get(i)).cloned() else {
|
||||
self.status = "Open a file first".to_string();
|
||||
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.
|
||||
let root = self.repo_root.clone();
|
||||
let Some(root) = root else {
|
||||
@@ -29,9 +40,10 @@ impl App {
|
||||
.strip_prefix(&root)
|
||||
.map(|p| p.to_string_lossy().replace('\\', "/"))
|
||||
.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() => {
|
||||
format!("{name} matches the last commit — no changes.")
|
||||
format!("{name} matches {label} — no changes.")
|
||||
}
|
||||
Ok(text) => text,
|
||||
Err(e) => format!("Could not diff {name}:\n{e}"),
|
||||
@@ -56,7 +68,7 @@ impl App {
|
||||
refresh = true;
|
||||
}
|
||||
ui.label(
|
||||
egui::RichText::new("compared with the last commit")
|
||||
egui::RichText::new(format!("compared with {}", self.diff_base_label))
|
||||
.small()
|
||||
.weak(),
|
||||
);
|
||||
@@ -92,7 +104,7 @@ impl App {
|
||||
});
|
||||
});
|
||||
if refresh {
|
||||
self.open_diff();
|
||||
self.open_diff_against(self.diff_base.clone(), self.diff_base_label.clone());
|
||||
}
|
||||
self.show_diff = open;
|
||||
}
|
||||
|
||||
+80
-3
@@ -39,6 +39,13 @@ impl App {
|
||||
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()
|
||||
.id_salt("editor")
|
||||
.auto_shrink([false, false])
|
||||
@@ -59,6 +66,7 @@ impl App {
|
||||
&find_ranges,
|
||||
find_active,
|
||||
&bible_ranges,
|
||||
dim_range,
|
||||
wrap_width,
|
||||
);
|
||||
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:
|
||||
// select the words, centre them, and hand focus back so the
|
||||
// user can type the correction straight away.
|
||||
if let Some((start, end)) = self.issue_jump.take() {
|
||||
jumped = true;
|
||||
let (cs, ce) = (
|
||||
byte_to_char(&self.buffer, start),
|
||||
byte_to_char(&self.buffer, end),
|
||||
@@ -170,6 +184,7 @@ impl App {
|
||||
// open, Next/Prev, Enter, or after a replace).
|
||||
if self.find_scroll {
|
||||
if let Some(&(s, _)) = self.find_matches.get(self.find_active) {
|
||||
jumped = true;
|
||||
let char_idx = self
|
||||
.buffer
|
||||
.get(..s)
|
||||
@@ -183,6 +198,25 @@ impl App {
|
||||
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.
|
||||
if output.response.secondary_clicked() {
|
||||
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_active: Option<usize>,
|
||||
bible: &[(usize, usize)],
|
||||
dim: Option<(usize, usize)>,
|
||||
wrap_width: f32,
|
||||
) -> egui::text::LayoutJob {
|
||||
use egui::text::{LayoutJob, TextFormat};
|
||||
@@ -489,13 +524,14 @@ pub(super) fn build_editor_job(
|
||||
let mut job = LayoutJob::default();
|
||||
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));
|
||||
return job;
|
||||
}
|
||||
|
||||
// Split the text at every match boundary (grammar, find, and bible), then format
|
||||
// each run according to which ranges cover it.
|
||||
// Split the text at every match boundary (grammar, find, bible, and the
|
||||
// typewriter-focus paragraph), then format each run according to which
|
||||
// ranges cover it.
|
||||
let mut points: Vec<usize> = vec![0, text.len()];
|
||||
for &(s, e, _) in grammar {
|
||||
points.push(s);
|
||||
@@ -509,6 +545,10 @@ pub(super) fn build_editor_job(
|
||||
points.push(s);
|
||||
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.sort_unstable();
|
||||
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) {
|
||||
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
|
||||
@@ -626,6 +672,17 @@ pub(super) fn byte_to_char(s: &str, byte: usize) -> usize {
|
||||
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*
|
||||
/// indices, returning the new text and the new selection (also character indices).
|
||||
///
|
||||
@@ -827,6 +884,7 @@ mod tests {
|
||||
&[],
|
||||
None,
|
||||
&[(0, 11)], // "Protagonist"
|
||||
None,
|
||||
f32::INFINITY,
|
||||
);
|
||||
let highlighted = job
|
||||
@@ -836,6 +894,25 @@ mod tests {
|
||||
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]
|
||||
fn byte_to_char_is_the_inverse_of_char_to_byte() {
|
||||
// "café!" — é is 2 bytes, so byte and char indices diverge after it.
|
||||
|
||||
+58
-2
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,17 @@ impl App {
|
||||
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.
|
||||
if let Some((goal, prose)) = goal {
|
||||
let (frac, color, text) = goal_progress(goal, prose);
|
||||
@@ -238,6 +249,14 @@ impl App {
|
||||
ui.close_menu();
|
||||
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() {
|
||||
ui.close_menu();
|
||||
self.show_snapshots = true;
|
||||
@@ -260,6 +279,10 @@ impl App {
|
||||
ui.close_menu();
|
||||
self.open_story_bible_editor();
|
||||
}
|
||||
if ui.button("🧾 Synopsis…").clicked() {
|
||||
ui.close_menu();
|
||||
self.show_synopsis = true;
|
||||
}
|
||||
ui.separator();
|
||||
let busy = self.beats_rx.is_some();
|
||||
if ui
|
||||
@@ -302,6 +325,17 @@ impl App {
|
||||
ui.close_menu();
|
||||
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();
|
||||
if ui
|
||||
.checkbox(&mut self.config.show_preview, "Preview pane")
|
||||
|
||||
@@ -78,6 +78,7 @@ impl App {
|
||||
self.manuscript_dir = self.detect_manuscript_dir(&ws);
|
||||
self.files = order::resolve_order(&ws, &self.config.hidden_folders);
|
||||
self.titles = order::read_titles(&ws);
|
||||
self.word_log = crate::word_log::WordLog::load(&ws);
|
||||
self.wordlist = crate::spell::read_wordlist(&ws);
|
||||
// Drop overrides for files that no longer exist.
|
||||
self.titles.retain(|name, _| self.files.contains(name));
|
||||
@@ -546,6 +547,12 @@ impl App {
|
||||
);
|
||||
self.file_meta.insert(name, meta);
|
||||
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}"),
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user