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:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
Reference in New Issue
Block a user