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:
@@ -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
|
rather than replacing, because the prompts are the reason the file is worth
|
||||||
keeping.
|
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
|
### Revision status
|
||||||
|
|
||||||
Give a file a `Status:` header line and the file panel shows a compact badge for
|
Give a file a `Status:` header line and the file panel shows a compact badge for
|
||||||
|
|||||||
@@ -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};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
mod autocomplete;
|
mod autocomplete;
|
||||||
|
mod auto_snapshot;
|
||||||
mod beats;
|
mod beats;
|
||||||
mod characters;
|
mod characters;
|
||||||
mod diff;
|
mod diff;
|
||||||
@@ -388,6 +389,12 @@ pub struct App {
|
|||||||
story_bible_editor_content: String,
|
story_bible_editor_content: String,
|
||||||
/// The workspace-relative path of the file being edited in the Story Bible editor.
|
/// The workspace-relative path of the file being edited in the Story Bible editor.
|
||||||
story_bible_editor_path: Option<String>,
|
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 {
|
impl App {
|
||||||
@@ -495,6 +502,9 @@ impl App {
|
|||||||
show_story_bible_editor: false,
|
show_story_bible_editor: false,
|
||||||
story_bible_editor_content: String::new(),
|
story_bible_editor_content: String::new(),
|
||||||
story_bible_editor_path: None,
|
story_bible_editor_path: None,
|
||||||
|
snapshots: Vec::new(),
|
||||||
|
last_snapshot_word_count: 0,
|
||||||
|
show_snapshots: false,
|
||||||
};
|
};
|
||||||
app.load_spell_dict();
|
app.load_spell_dict();
|
||||||
app.open_workspace();
|
app.open_workspace();
|
||||||
@@ -625,6 +635,10 @@ impl eframe::App for App {
|
|||||||
self.story_bible_editor_window(ctx);
|
self.story_bible_editor_window(ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.show_snapshots {
|
||||||
|
self.snapshots_window(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
if self.show_manuscript_settings {
|
if self.show_manuscript_settings {
|
||||||
self.manuscript_settings_window(ctx);
|
self.manuscript_settings_window(ctx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -325,6 +325,10 @@ impl App {
|
|||||||
ui.close_menu();
|
ui.close_menu();
|
||||||
self.open_story_bible_editor();
|
self.open_story_bible_editor();
|
||||||
}
|
}
|
||||||
|
if ui.button("📸 Snapshots…").clicked() {
|
||||||
|
ui.close_menu();
|
||||||
|
self.show_snapshots = true;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
ui.menu_button("Tools", |ui| {
|
ui.menu_button("Tools", |ui| {
|
||||||
if ui.button("☑ Outline…").clicked() {
|
if ui.button("☑ Outline…").clicked() {
|
||||||
@@ -581,6 +585,9 @@ impl App {
|
|||||||
} else {
|
} else {
|
||||||
self.editor(ui);
|
self.editor(ui);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for auto-snapshot after editor updates
|
||||||
|
self.check_auto_snapshot();
|
||||||
}
|
}
|
||||||
None => {
|
None => {
|
||||||
ui.centered_and_justified(|ui| {
|
ui.centered_and_justified(|ui| {
|
||||||
|
|||||||
@@ -164,6 +164,17 @@ pub struct Config {
|
|||||||
/// paginated screenplay PDF instead of an ODT.
|
/// paginated screenplay PDF instead of an ODT.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub manuscript_screenplay: bool,
|
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
|
/// 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_title_page: false,
|
||||||
manuscript_standard_format: false,
|
manuscript_standard_format: false,
|
||||||
manuscript_screenplay: false,
|
manuscript_screenplay: false,
|
||||||
|
auto_snapshot_enabled: false,
|
||||||
|
auto_snapshot_threshold: default_auto_snapshot_threshold(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ pub struct GitOutcome {
|
|||||||
pub log: String,
|
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")
|
let output = Command::new("git")
|
||||||
.current_dir(workspace)
|
.current_dir(workspace)
|
||||||
.args(args)
|
.args(args)
|
||||||
|
|||||||
Reference in New Issue
Block a user