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 <vibe@mistral.ai>
This commit is contained in:
landon
2026-09-16 12:11:35 -05:00
parent dcb97da518
commit aafe1786b5
6 changed files with 378 additions and 1 deletions
+320
View File
@@ -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<String> {
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;
}
}
}
+14
View File
@@ -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<String>,
/// List of auto-snapshots for rollback.
snapshots: Vec<crate::app::auto_snapshot::SnapshotInfo>,
/// 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);
}
+7
View File
@@ -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| {