Skip to main content

fresh/app/
recovery_actions.rs

1//! Recovery and auto-save operations for the Editor.
2//!
3//! This module contains crash recovery and auto-save functionality:
4//! - Starting/ending recovery sessions
5//! - Checking for and listing recoverable files
6//! - Recovering buffers from crash
7//! - Auto-saving modified buffers
8//! - Cleaning up recovery files
9
10use anyhow::Result as AnyhowResult;
11
12use crate::model::event::BufferId;
13
14use super::Editor;
15
16impl Editor {
17    /// Start the recovery session (call on editor startup after recovery check)
18    pub fn start_recovery_session(&mut self) -> AnyhowResult<()> {
19        Ok(self.recovery_service.start_session()?)
20    }
21
22    /// End the recovery session cleanly (call on normal shutdown)
23    pub fn end_recovery_session(&mut self) -> AnyhowResult<()> {
24        Ok(self.recovery_service.end_session()?)
25    }
26
27    /// Check if there are files to recover from a crash
28    pub fn has_recovery_files(&self) -> AnyhowResult<bool> {
29        Ok(self.recovery_service.should_offer_recovery()?)
30    }
31
32    /// Get list of recoverable files
33    pub fn list_recoverable_files(
34        &self,
35    ) -> AnyhowResult<Vec<crate::services::recovery::RecoveryEntry>> {
36        Ok(self.recovery_service.list_recoverable()?)
37    }
38
39    /// Recover all buffers from recovery files
40    /// Returns the number of buffers recovered
41    pub fn recover_all_buffers(&mut self) -> AnyhowResult<usize> {
42        use crate::services::recovery::RecoveryResult;
43
44        let entries = self.recovery_service.list_recoverable()?;
45        let mut recovered_count = 0;
46
47        for entry in entries {
48            match self.recovery_service.accept_recovery(&entry) {
49                Ok(RecoveryResult::Recovered {
50                    original_path,
51                    content,
52                }) => {
53                    // Full content recovery (new/small buffers)
54                    let text = String::from_utf8_lossy(&content).into_owned();
55
56                    if let Some(path) = original_path {
57                        // Open the file path (this creates the buffer)
58                        if self.open_file(&path).is_ok() {
59                            // Replace buffer content with recovered content
60                            let state = self.active_state_mut();
61                            let total = state.buffer.total_bytes();
62                            state.buffer.delete(0..total);
63                            state.buffer.insert(0, &text);
64                            // Mark as modified since it differs from disk
65                            state.buffer.set_modified(true);
66                            recovered_count += 1;
67                            tracing::info!("Recovered buffer: {}", path.display());
68                        }
69                    } else {
70                        // Unsaved buffer - create new buffer with recovered content
71                        self.new_buffer();
72                        let state = self.active_state_mut();
73                        state.buffer.insert(0, &text);
74                        state.buffer.set_modified(true);
75                        recovered_count += 1;
76                        tracing::info!("Recovered unsaved buffer");
77                    }
78                }
79                Ok(RecoveryResult::RecoveredChunks {
80                    original_path,
81                    chunks,
82                }) => {
83                    // Chunked recovery for large files - apply chunks directly
84                    if self.open_file(&original_path).is_ok() {
85                        let state = self.active_state_mut();
86
87                        // Apply chunks in reverse order to preserve offsets
88                        // Each chunk: delete original_len bytes at offset, then insert content
89                        for chunk in chunks.into_iter().rev() {
90                            let text = String::from_utf8_lossy(&chunk.content).into_owned();
91                            if chunk.original_len > 0 {
92                                state
93                                    .buffer
94                                    .delete(chunk.offset..chunk.offset + chunk.original_len);
95                            }
96                            state.buffer.insert(chunk.offset, &text);
97                        }
98
99                        // Mark as modified since it differs from disk
100                        state.buffer.set_modified(true);
101                        recovered_count += 1;
102                        tracing::info!("Recovered buffer with chunks: {}", original_path.display());
103                    }
104                }
105                Ok(RecoveryResult::OriginalFileModified { id, original_path }) => {
106                    tracing::warn!(
107                        "Recovery file {} skipped: original file {} was modified",
108                        id,
109                        original_path.display()
110                    );
111                    // Delete the recovery file since it's no longer valid
112                    let _ = self.recovery_service.discard_recovery(&entry);
113                }
114                Ok(RecoveryResult::Corrupted { id, reason }) => {
115                    tracing::warn!("Recovery file {} corrupted: {}", id, reason);
116                }
117                Ok(RecoveryResult::NotFound { id }) => {
118                    tracing::warn!("Recovery file {} not found", id);
119                }
120                Err(e) => {
121                    tracing::warn!("Failed to recover {}: {}", entry.id, e);
122                }
123            }
124        }
125
126        Ok(recovered_count)
127    }
128
129    /// Discard all recovery files (user decided not to recover)
130    /// Returns the number of recovery files deleted
131    pub fn discard_all_recovery(&mut self) -> AnyhowResult<usize> {
132        Ok(self.recovery_service.discard_all_recovery()?)
133    }
134
135    /// Perform auto-save for all modified buffers if needed
136    /// Returns the number of buffers saved, or an error
137    ///
138    /// This function is designed to be called frequently (every frame) and will:
139    /// - Return immediately if recovery is disabled
140    /// - Return immediately if the auto-save interval hasn't passed
141    /// - Return immediately if no buffers are modified
142    /// - Only save buffers that are marked as needing a save
143    pub fn auto_save_dirty_buffers(&mut self) -> AnyhowResult<usize> {
144        // Early exit if disabled
145        if !self.recovery_service.is_enabled() {
146            return Ok(0);
147        }
148
149        // Check if enough time has passed since last auto-save
150        let interval =
151            std::time::Duration::from_secs(self.config.editor.auto_save_interval_secs as u64);
152        if self.time_source.elapsed_since(self.last_auto_save) < interval {
153            return Ok(0);
154        }
155
156        // Collect buffer IDs that need recovery first (immutable pass)
157        // Skip composite buffers and hidden buffers (they should not be saved for recovery)
158        let buffers_needing_recovery: Vec<_> = self
159            .buffers
160            .iter()
161            .filter_map(|(buffer_id, state)| {
162                // Skip composite buffers - they are virtual views, not real content
163                if state.is_composite_buffer {
164                    return None;
165                }
166                // Skip hidden buffers - they are managed by other buffers (e.g., diff sources)
167                if let Some(meta) = self.buffer_metadata.get(buffer_id) {
168                    if meta.hidden_from_tabs || meta.is_virtual() {
169                        return None;
170                    }
171                }
172                if state.buffer.is_recovery_pending() {
173                    Some(*buffer_id)
174                } else {
175                    None
176                }
177            })
178            .collect();
179
180        // Ensure unnamed buffers have stable recovery IDs (mutable pass)
181        // For file-backed buffers, recovery_id is computed from path hash (stable).
182        // For unnamed buffers, we generate once and store in metadata.
183        for buffer_id in &buffers_needing_recovery {
184            let needs_id = self
185                .buffer_metadata
186                .get(buffer_id)
187                .map(|meta| {
188                    let path = meta.file_path();
189                    let is_unnamed = path.map(|p| p.as_os_str().is_empty()).unwrap_or(true);
190                    is_unnamed && meta.recovery_id.is_none()
191                })
192                .unwrap_or(false);
193
194            if needs_id {
195                let new_id = crate::services::recovery::generate_buffer_id();
196                if let Some(meta) = self.buffer_metadata.get_mut(buffer_id) {
197                    meta.recovery_id = Some(new_id);
198                }
199            }
200        }
201
202        // Now collect full buffer info with stable recovery IDs
203        let buffer_info: Vec<_> = buffers_needing_recovery
204            .into_iter()
205            .filter_map(|buffer_id| {
206                let state = self.buffers.get(&buffer_id)?;
207                let meta = self.buffer_metadata.get(&buffer_id)?;
208
209                let path = state.buffer.file_path().map(|p| p.to_path_buf());
210
211                // Get recovery_id: use stored one for unnamed buffers, compute from path otherwise
212                let recovery_id = if let Some(ref stored_id) = meta.recovery_id {
213                    stored_id.clone()
214                } else {
215                    self.recovery_service.get_buffer_id(path.as_deref())
216                };
217
218                // Only save if enough time has passed since last recovery save
219                let recovery_pending = state.buffer.is_recovery_pending();
220                if self
221                    .recovery_service
222                    .needs_auto_save(&recovery_id, recovery_pending)
223                {
224                    Some((buffer_id, recovery_id, path))
225                } else {
226                    None
227                }
228            })
229            .collect();
230
231        // Early exit if nothing to save
232        if buffer_info.is_empty() {
233            // Still update the timer to avoid checking buffers too frequently
234            self.last_auto_save = self.time_source.now();
235            return Ok(0);
236        }
237
238        let mut saved_count = 0;
239
240        for (buffer_id, recovery_id, path) in buffer_info {
241            if let Some(state) = self.buffers.get_mut(&buffer_id) {
242                let line_count = state.buffer.line_count();
243
244                // For large files, use chunked recovery to avoid reading entire file
245                if state.buffer.is_large_file() {
246                    let chunks = state.buffer.get_recovery_chunks();
247
248                    // If no modifications, skip saving (original file is recovery)
249                    if chunks.is_empty() {
250                        state.buffer.set_recovery_pending(false);
251                        continue;
252                    }
253
254                    // Convert to RecoveryChunk format
255                    let recovery_chunks: Vec<_> = chunks
256                        .into_iter()
257                        .map(|(offset, content)| {
258                            crate::services::recovery::types::RecoveryChunk::new(
259                                offset, 0, // For insertions, original_len is 0
260                                content,
261                            )
262                        })
263                        .collect();
264
265                    let original_size = state.buffer.original_file_size().unwrap_or(0);
266                    let final_size = state.buffer.total_bytes();
267
268                    tracing::debug!(
269                        "auto_save_dirty_buffers: large file recovery - original_size={}, final_size={}, path={:?}",
270                        original_size,
271                        final_size,
272                        path
273                    );
274
275                    self.recovery_service.save_buffer(
276                        &recovery_id,
277                        recovery_chunks,
278                        path.as_deref(),
279                        None,
280                        line_count,
281                        original_size,
282                        final_size,
283                    )?;
284
285                    tracing::debug!(
286                        "Saved chunked recovery for large file (original: {} bytes, final: {} bytes)",
287                        original_size,
288                        final_size
289                    );
290                } else {
291                    // For small files, save full content as a single chunk
292                    let total_bytes = state.buffer.total_bytes();
293                    let content = match state.buffer.get_text_range_mut(0, total_bytes) {
294                        Ok(bytes) => bytes,
295                        Err(e) => {
296                            tracing::warn!("Failed to get buffer content for recovery save: {}", e);
297                            continue;
298                        }
299                    };
300
301                    let chunks = vec![crate::services::recovery::types::RecoveryChunk::new(
302                        0, 0, content,
303                    )];
304                    self.recovery_service.save_buffer(
305                        &recovery_id,
306                        chunks,
307                        path.as_deref(),
308                        None,
309                        line_count,
310                        0,           // original_file_size = 0 for new/small files
311                        total_bytes, // final_size
312                    )?;
313                }
314
315                // Clear recovery_pending flag after successful save
316                state.buffer.set_recovery_pending(false);
317                saved_count += 1;
318            }
319        }
320
321        self.last_auto_save = self.time_source.now();
322        Ok(saved_count)
323    }
324
325    /// Check if the active buffer is marked dirty for recovery auto-save
326    /// Used for testing to verify that edits properly trigger recovery tracking
327    pub fn is_active_buffer_recovery_dirty(&self) -> bool {
328        if let Some(state) = self.buffers.get(&self.active_buffer()) {
329            state.buffer.is_recovery_pending()
330        } else {
331            false
332        }
333    }
334
335    /// Delete recovery for a buffer (call after saving or closing)
336    pub fn delete_buffer_recovery(&mut self, buffer_id: BufferId) -> AnyhowResult<()> {
337        // Get recovery_id: use stored one for unnamed buffers, compute from path otherwise
338        let recovery_id = {
339            let meta = self.buffer_metadata.get(&buffer_id);
340            let state = self.buffers.get(&buffer_id);
341
342            if let Some(stored_id) = meta.and_then(|m| m.recovery_id.clone()) {
343                stored_id
344            } else if let Some(state) = state {
345                let path = state.buffer.file_path().map(|p| p.to_path_buf());
346                self.recovery_service.get_buffer_id(path.as_deref())
347            } else {
348                return Ok(());
349            }
350        };
351
352        self.recovery_service.delete_buffer_recovery(&recovery_id)?;
353
354        // Clear recovery_pending since buffer is now saved
355        if let Some(state) = self.buffers.get_mut(&buffer_id) {
356            state.buffer.set_recovery_pending(false);
357        }
358        Ok(())
359    }
360}