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