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.
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
View File
@@ -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
View File
@@ -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
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);
+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)",
);
// 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")
+7
View File
@@ -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}"),
}
+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()
}
}