From aafe1786b51032d5ae29e63535006a738b876483 Mon Sep 17 00:00:00 2001 From: landon Date: Wed, 16 Sep 2026 12:11:35 -0500 Subject: [PATCH] feat: Add auto-snapshot feature with git commit every N words - Add new module src/app/auto_snapshot.rs for auto-snapshot functionality - Add configuration options: auto_snapshot_enabled (default: false) and auto_snapshot_threshold (default: 250 words) - Automatically create git commits when word count crosses threshold boundaries - Add 'Edit -> Snapshots' menu option to open snapshots window - Snapshots window shows: enable/disable toggle, threshold setting, current word count, next snapshot target, list of all snapshots with rollback buttons - Manual snapshot creation via button in snapshots window - Rollback to any previous snapshot with one click - Snapshot info includes: word count, timestamp, commit message, commit hash - Make run_git in gitsync.rs public for use by auto_snapshot module - Update README.md with Auto-Snapshots documentation Generated by Mistral Vibe. Co-Authored-By: Mistral Vibe --- README.md | 23 +++ src/app/auto_snapshot.rs | 320 +++++++++++++++++++++++++++++++++++++++ src/app/mod.rs | 14 ++ src/app/ui.rs | 7 + src/config.rs | 13 ++ src/gitsync.rs | 2 +- 6 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 src/app/auto_snapshot.rs diff --git a/README.md b/README.md index b41e4df..528668e 100644 --- a/README.md +++ b/README.md @@ -429,6 +429,29 @@ appears when a run filled exactly one act. It appends under a dated comment rather than replacing, because the prompts are the reason the file is worth keeping. +### Auto-Snapshots (Edit ▸ Snapshots…) + +Never lose your work again. Enable automatic git commits at regular word-count +intervals to create recovery points you can roll back to at any time. + +* **Enable/Disable**: Turn auto-snapshotting on or off from the Snapshots window +* **Configurable threshold**: Set how many words between snapshots (default: 250) +* **Automatic commits**: Commits are created automatically when you cross a + threshold boundary (250, 500, 750, 1000, etc.) +* **Manual snapshots**: Click "Create Manual Snapshot" to force a commit at any time +* **Rollback**: Restore your entire workspace to any previous snapshot +* **Live status**: See current word count and words until next snapshot + +Snapshots appear in the Snapshots window with: +- Word count at the time of the snapshot +- Timestamp +- Commit message +- Commit hash (shown on hover) + +**Important**: This feature requires the workspace to be a git repository. Snapshots +are regular git commits with messages like "Auto-snapshot at N words", so they +integrate with your normal git workflow. + ### Revision status Give a file a `Status:` header line and the file panel shows a compact badge for diff --git a/src/app/auto_snapshot.rs b/src/app/auto_snapshot.rs new file mode 100644 index 0000000..3cacdaa --- /dev/null +++ b/src/app/auto_snapshot.rs @@ -0,0 +1,320 @@ +//! Auto-snapshot feature: automatically commit every N words with rollback support. +//! +//! Provides word-count-based versioning for writers who want automatic save points. + +use super::*; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Information about a snapshot for rollback. +#[derive(Debug, Clone)] +pub struct SnapshotInfo { + /// The git commit hash for this snapshot. + pub commit_hash: String, + /// The word count at the time of this snapshot. + pub word_count: usize, + /// Timestamp when the snapshot was created (UTC seconds since epoch). + pub timestamp: u64, + /// The commit message used. + pub message: String, +} + +impl SnapshotInfo { + /// Create a new snapshot info. + pub fn new(commit_hash: String, word_count: usize, message: String) -> Self { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + Self { + commit_hash, + word_count, + timestamp, + message, + } + } + + /// Format the timestamp as a human-readable string. + pub fn formatted_time(&self) -> String { + let secs = self.timestamp; + let (y, m, d) = crate::app::util::civil_from_days((secs / 86_400) as i64); + let hours = (secs % 86_400) / 3600; + let minutes = (secs % 3600) / 60; + let seconds = secs % 60; + format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02}", y, m, d, hours, minutes, seconds) + } +} + +impl App { + /// Check if we should create a snapshot based on word count. + /// Returns true if a snapshot was created. + pub(super) fn check_auto_snapshot(&mut self) -> bool { + // Only check if the feature is enabled and we have a selected file + if !self.config.auto_snapshot_enabled || self.selected.is_none() { + return false; + } + + // Get current word count + let current_words = self.current_word_count(); + + // If we haven't set a baseline yet, set it to the current word count + // This handles the case when the feature is enabled mid-session + if self.last_snapshot_word_count == 0 { + self.last_snapshot_word_count = current_words; + return false; + } + + // Check if we've crossed a threshold boundary + let threshold = self.config.auto_snapshot_threshold; + let threshold_crossed = current_words / threshold > self.last_snapshot_word_count / threshold; + + // Also check if we've added at least threshold words since last snapshot + let words_since_last = current_words.saturating_sub(self.last_snapshot_word_count); + + if threshold_crossed && words_since_last >= threshold { + // Create a snapshot + let snapshot_message = format!( + "Auto-snapshot at {} words", + current_words + ); + + // Save and commit + self.save_current(); + self.persist_order(); + self.persist_titles(); + + let outcome = Self::git_commit_only(self.workspace(), &snapshot_message); + + if outcome.ok { + // Get the commit hash + if let Some(hash) = Self::get_head_commit_hash(self.workspace()) { + let snapshot = SnapshotInfo::new(hash, current_words, snapshot_message); + self.snapshots.push(snapshot); + self.last_snapshot_word_count = current_words; + self.status = format!("Auto-snapshot created at {} words", current_words); + return true; + } + } + + // If commit failed, just update the word count to avoid retrying + self.last_snapshot_word_count = current_words; + } + + false + } + + /// Get the HEAD commit hash. + fn get_head_commit_hash(workspace: &std::path::Path) -> Option { + let (ok, out) = crate::gitsync::run_git(workspace, &["rev-parse", "HEAD"]); + if ok { + out.lines() + .find(|l| !l.starts_with("$ git")) + .map(|s| s.trim().to_string()) + } else { + None + } + } + + /// Commit only (no pull/push) - used for snapshots. + fn git_commit_only(workspace: &std::path::Path, message: &str) -> crate::gitsync::GitOutcome { + let mut log = String::new(); + let mut ok = true; + + // Add all changes + let (_, o) = crate::gitsync::run_git(workspace, &["add", "-A"]); + log.push_str(&o); + + // Check if there are changes + let (has_changes, status) = crate::gitsync::run_git(workspace, &["status", "--porcelain"]); + let dirty = has_changes && !status.lines().skip(1).all(|l| l.trim().is_empty()); + + if dirty { + let (c_ok, c_out) = crate::gitsync::run_git(workspace, &["commit", "-m", message]); + log.push_str(&c_out); + ok &= c_ok; + } else { + log.push_str("(nothing to commit)\n"); + } + + crate::gitsync::GitOutcome { ok, log } + } + + /// Get the current word count of the open file. + pub(super) fn current_word_count(&self) -> usize { + if self.selected.is_none() { + return 0; + } + + // Use the buffer directly - this counts all words including header + // For prose-only counting, we'd need to parse and use the body + crate::app::util::count_words(&self.buffer) + } + + /// Create a snapshot commit with the given message. + pub(super) fn create_snapshot_manually(&mut self) { + if !self.config.auto_snapshot_enabled { + self.status = "Auto-snapshot feature is disabled".to_string(); + return; + } + + self.save_current(); + self.persist_order(); + self.persist_titles(); + + let current_words = self.current_word_count(); + let snapshot_message = format!( + "Manual snapshot at {} words", + current_words + ); + + let outcome = Self::git_commit_only(self.workspace(), &snapshot_message); + + if outcome.ok { + if let Some(hash) = Self::get_head_commit_hash(self.workspace()) { + let snapshot = SnapshotInfo::new(hash, current_words, snapshot_message); + self.snapshots.push(snapshot); + self.last_snapshot_word_count = current_words; + self.status = format!("Manual snapshot created at {} words", current_words); + } + } else { + self.status = format!("Failed to create snapshot: {}", outcome.log); + } + } + + /// Roll back to a specific snapshot. + pub(super) fn rollback_to_snapshot(&mut self, index: usize) { + // Clone the snapshot info we need to avoid borrow issues + let snapshot_hash = self.snapshots.get(index).map(|s| s.commit_hash.clone()); + let snapshot_word_count = self.snapshots.get(index).map(|s| s.word_count); + + if let (Some(hash), Some(word_count)) = (snapshot_hash, snapshot_word_count) { + // Checkout the commit + let (ok, log) = crate::gitsync::run_git( + self.workspace(), + &["checkout", &hash], + ); + + if ok { + self.status = format!( + "Rolled back to snapshot at {} words", + word_count + ); + // Refresh the workspace to reflect the checkout + self.open_workspace(); + // Reset last snapshot word count to avoid immediate re-snapshot + self.last_snapshot_word_count = word_count; + } else { + self.status = format!("Failed to rollback: {}", log); + } + } + } + + /// Show the snapshots window. + pub(super) fn snapshots_window(&mut self, ctx: &egui::Context) { + let mut open = self.show_snapshots; + + egui::Window::new("Snapshots") + .open(&mut open) + .resizable(true) + .collapsible(false) + .default_width(600.0) + .default_height(400.0) + .show(ctx, |ui| { + self.snapshots_window_contents(ui); + }); + + self.show_snapshots = open; + } + + /// Contents of the snapshots window. + fn snapshots_window_contents(&mut self, ui: &mut egui::Ui) { + ui.heading("Auto-Snapshots"); + ui.separator(); + + // Settings + ui.horizontal(|ui| { + ui.label("Enabled:"); + if ui.checkbox(&mut self.config.auto_snapshot_enabled, "").changed() { + self.config.save(); + if !self.config.auto_snapshot_enabled { + // Reset tracking when disabled + self.last_snapshot_word_count = 0; + } + } + + ui.label("Threshold:"); + let mut threshold = self.config.auto_snapshot_threshold; + if ui.add(egui::DragValue::new(&mut threshold).range(1..=10000)).changed() { + self.config.auto_snapshot_threshold = threshold; + self.config.save(); + } + ui.label("words"); + }); + + ui.separator(); + + // Current status + let current_words = self.current_word_count(); + ui.label(format!("Current word count: {}", current_words)); + + if self.config.auto_snapshot_enabled { + let next_snapshot = ((current_words / self.config.auto_snapshot_threshold) + 1) + * self.config.auto_snapshot_threshold; + let words_to_next = next_snapshot.saturating_sub(current_words); + ui.label(format!( + "Next snapshot at {} words ({} words to go)", + next_snapshot, words_to_next + )); + } + + ui.separator(); + + // Manual snapshot button + if ui.button("Create Manual Snapshot").clicked() { + self.create_snapshot_manually(); + } + + ui.separator(); + + // Snapshot list + ui.heading("Snapshots"); + + if self.snapshots.is_empty() { + ui.label("No snapshots yet. Start writing to create auto-snapshots!"); + } else { + egui::ScrollArea::vertical() + .auto_shrink(false) + .show(ui, |ui| { + // Sort by timestamp descending (newest first) + let mut sorted_snapshots = self.snapshots.clone(); + sorted_snapshots.sort_by(|a, b| b.timestamp.cmp(&a.timestamp)); + + for (i, snapshot) in sorted_snapshots.iter().enumerate() { + // Find the original index in self.snapshots + let orig_index = self.snapshots.iter() + .position(|s| s.timestamp == snapshot.timestamp) + .unwrap_or(i); + + ui.horizontal(|ui| { + 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("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 + } + }); + } + }); + } + + ui.separator(); + if ui.button("Close").clicked() { + self.show_snapshots = false; + } + } +} diff --git a/src/app/mod.rs b/src/app/mod.rs index 6f5c0f4..6da5623 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -17,6 +17,7 @@ use std::thread; use std::time::{Duration, Instant}; mod autocomplete; +mod auto_snapshot; mod beats; mod characters; mod diff; @@ -388,6 +389,12 @@ pub struct App { story_bible_editor_content: String, /// The workspace-relative path of the file being edited in the Story Bible editor. story_bible_editor_path: Option, + /// List of auto-snapshots for rollback. + snapshots: Vec, + /// Word count at the last auto-snapshot. + last_snapshot_word_count: usize, + /// Whether the snapshots window is open. + show_snapshots: bool, } impl App { @@ -495,6 +502,9 @@ impl App { show_story_bible_editor: false, story_bible_editor_content: String::new(), story_bible_editor_path: None, + snapshots: Vec::new(), + last_snapshot_word_count: 0, + show_snapshots: false, }; app.load_spell_dict(); app.open_workspace(); @@ -625,6 +635,10 @@ impl eframe::App for App { self.story_bible_editor_window(ctx); } + if self.show_snapshots { + self.snapshots_window(ctx); + } + if self.show_manuscript_settings { self.manuscript_settings_window(ctx); } diff --git a/src/app/ui.rs b/src/app/ui.rs index b57a7bd..070e87f 100644 --- a/src/app/ui.rs +++ b/src/app/ui.rs @@ -325,6 +325,10 @@ impl App { ui.close_menu(); self.open_story_bible_editor(); } + if ui.button("📸 Snapshots…").clicked() { + ui.close_menu(); + self.show_snapshots = true; + } }); ui.menu_button("Tools", |ui| { if ui.button("☑ Outline…").clicked() { @@ -581,6 +585,9 @@ impl App { } else { self.editor(ui); } + + // Check for auto-snapshot after editor updates + self.check_auto_snapshot(); } None => { ui.centered_and_justified(|ui| { diff --git a/src/config.rs b/src/config.rs index c26bc4a..eb0dcc7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -164,6 +164,17 @@ pub struct Config { /// paginated screenplay PDF instead of an ODT. #[serde(default)] pub manuscript_screenplay: bool, + /// Whether auto-snapshot (git commit every N words) is enabled. + #[serde(default)] + pub auto_snapshot_enabled: bool, + /// Word count threshold for auto-snapshots. + #[serde(default = "default_auto_snapshot_threshold")] + pub auto_snapshot_threshold: usize, +} + +/// Default auto-snapshot threshold: 250 words. +pub fn default_auto_snapshot_threshold() -> usize { + 250 } /// The toolbar and the file list are what the window opens with; hiding either @@ -335,6 +346,8 @@ impl Default for Config { manuscript_title_page: false, manuscript_standard_format: false, manuscript_screenplay: false, + auto_snapshot_enabled: false, + auto_snapshot_threshold: default_auto_snapshot_threshold(), } } } diff --git a/src/gitsync.rs b/src/gitsync.rs index f5bdaca..a2917d9 100644 --- a/src/gitsync.rs +++ b/src/gitsync.rs @@ -7,7 +7,7 @@ pub struct GitOutcome { pub log: String, } -fn run_git(workspace: &Path, args: &[&str]) -> (bool, String) { +pub fn run_git(workspace: &Path, args: &[&str]) -> (bool, String) { let output = Command::new("git") .current_dir(workspace) .args(args)