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        let hot_exit = self.config.editor.hot_exit;
25
26        if hot_exit {
27            // Force all modified buffers to be re-saved by marking them pending,
28            // then reuse the existing periodic recovery save logic.
29            for (_, state) in self.buffers.iter_mut() {
30                if state.buffer.is_modified() {
31                    state.buffer.set_recovery_pending(true);
32                }
33            }
34            self.save_pending_recovery_buffers()?;
35
36            // Collect recovery IDs for buffers that should survive this session
37            let preserve_ids = self.recovery_ids_to_preserve();
38            Ok(self
39                .recovery_service
40                .end_session_preserving(&preserve_ids)?)
41        } else {
42            Ok(self.recovery_service.end_session()?)
43        }
44    }
45
46    /// Collect recovery IDs for all buffers that should be preserved across sessions.
47    fn recovery_ids_to_preserve(&self) -> Vec<String> {
48        let hot_exit = self.config.editor.hot_exit;
49
50        self.buffer_metadata
51            .iter()
52            .filter_map(|(buffer_id, meta)| {
53                if meta.hidden_from_tabs || meta.is_virtual() {
54                    return None;
55                }
56                if !hot_exit {
57                    return None;
58                }
59                let state = self.buffers.get(buffer_id)?;
60                if !state.buffer.is_modified() {
61                    return None;
62                }
63                let path = meta.file_path()?;
64                let is_unnamed = path.as_os_str().is_empty();
65                if is_unnamed && state.buffer.total_bytes() == 0 {
66                    return None;
67                }
68                // Use stored recovery_id, or compute from path for file-backed buffers
69                meta.recovery_id.clone().or_else(|| {
70                    let file_path = state.buffer.file_path().map(|p| p.to_path_buf());
71                    Some(self.recovery_service.get_buffer_id(file_path.as_deref()))
72                })
73            })
74            .collect()
75    }
76
77    /// Check if there are files to recover from a crash
78    pub fn has_recovery_files(&self) -> AnyhowResult<bool> {
79        Ok(self.recovery_service.should_offer_recovery()?)
80    }
81
82    /// Get list of recoverable files
83    pub fn list_recoverable_files(
84        &self,
85    ) -> AnyhowResult<Vec<crate::services::recovery::RecoveryEntry>> {
86        Ok(self.recovery_service.list_recoverable()?)
87    }
88
89    /// Recover all buffers from recovery files
90    /// Returns the number of buffers recovered
91    pub fn recover_all_buffers(&mut self) -> AnyhowResult<usize> {
92        use crate::services::recovery::RecoveryResult;
93
94        let entries = self.recovery_service.list_recoverable()?;
95        let mut recovered_count = 0;
96
97        for entry in entries {
98            match self.recovery_service.accept_recovery(&entry) {
99                Ok(RecoveryResult::Recovered {
100                    original_path,
101                    content,
102                }) => {
103                    // Full content recovery (new/small buffers)
104                    let text = String::from_utf8_lossy(&content).into_owned();
105
106                    if let Some(path) = original_path {
107                        // Open the file path (this creates the buffer)
108                        match self.open_file(&path) {
109                            Ok(_) => {
110                                // Replace buffer content with recovered content
111                                {
112                                    let state = self.active_state_mut();
113                                    let total = state.buffer.total_bytes();
114                                    state.buffer.delete(0..total);
115                                    state.buffer.insert(0, &text);
116                                    // Mark as modified since it differs from disk
117                                    state.buffer.set_modified(true);
118                                }
119                                // Invalidate the event log's saved position so undo
120                                // can't incorrectly clear the modified flag
121                                self.active_event_log_mut().clear_saved_position();
122                                recovered_count += 1;
123                                tracing::info!("Recovered buffer: {}", path.display());
124                            }
125                            Err(e) => {
126                                // Check if this is a large file encoding confirmation error
127                                if let Some(confirmation) = e.downcast_ref::<
128                                    crate::model::buffer::LargeFileEncodingConfirmation,
129                                >() {
130                                    self.start_large_file_encoding_confirmation(confirmation);
131                                } else {
132                                    tracing::warn!("Failed to recover buffer {}: {}", path.display(), e);
133                                }
134                            }
135                        }
136                    } else {
137                        // Unsaved buffer - create new buffer with recovered content
138                        self.new_buffer();
139                        {
140                            let state = self.active_state_mut();
141                            state.buffer.insert(0, &text);
142                            state.buffer.set_modified(true);
143                        }
144                        // Invalidate the event log's saved position so undo
145                        // can't incorrectly clear the modified flag
146                        self.active_event_log_mut().clear_saved_position();
147                        recovered_count += 1;
148                        tracing::info!("Recovered unsaved buffer");
149                    }
150                }
151                Ok(RecoveryResult::RecoveredChunks {
152                    original_path,
153                    chunks,
154                }) => {
155                    // Chunked recovery for large files - apply chunks directly
156                    if self.open_file(&original_path).is_ok() {
157                        {
158                            let state = self.active_state_mut();
159
160                            // Apply chunks in reverse order to preserve offsets
161                            // Each chunk: delete original_len bytes at offset, then insert content
162                            for chunk in chunks.into_iter().rev() {
163                                let text = String::from_utf8_lossy(&chunk.content).into_owned();
164                                if chunk.original_len > 0 {
165                                    state
166                                        .buffer
167                                        .delete(chunk.offset..chunk.offset + chunk.original_len);
168                                }
169                                state.buffer.insert(chunk.offset, &text);
170                            }
171
172                            // Mark as modified since it differs from disk
173                            state.buffer.set_modified(true);
174                        }
175                        // Invalidate the event log's saved position so undo
176                        // can't incorrectly clear the modified flag
177                        self.active_event_log_mut().clear_saved_position();
178                        recovered_count += 1;
179                        tracing::info!("Recovered buffer with chunks: {}", original_path.display());
180                    }
181                }
182                Ok(RecoveryResult::OriginalFileModified { id, original_path }) => {
183                    tracing::warn!(
184                        "Recovery file {} skipped: original file {} was modified",
185                        id,
186                        original_path.display()
187                    );
188                    // Keep the recovery file so the user can manually inspect it.
189                    // Show a warning so the user knows unsaved changes exist.
190                    let name = original_path
191                        .file_name()
192                        .unwrap_or_default()
193                        .to_string_lossy();
194                    self.set_status_message(format!(
195                        "{} changed on disk; unsaved changes not restored",
196                        name
197                    ));
198                }
199                Ok(RecoveryResult::Corrupted { id, reason }) => {
200                    tracing::warn!("Recovery file {} corrupted: {}", id, reason);
201                }
202                Ok(RecoveryResult::NotFound { id }) => {
203                    tracing::warn!("Recovery file {} not found", id);
204                }
205                Err(e) => {
206                    tracing::warn!("Failed to recover {}: {}", entry.id, e);
207                }
208            }
209        }
210
211        Ok(recovered_count)
212    }
213
214    /// Discard all recovery files (user decided not to recover)
215    /// Returns the number of recovery files deleted
216    pub fn discard_all_recovery(&mut self) -> AnyhowResult<usize> {
217        Ok(self.recovery_service.discard_all_recovery()?)
218    }
219
220    /// Perform auto-recovery-save for all modified buffers if needed.
221    /// Called frequently (every frame); rate-limited by `auto_recovery_save_interval_secs`.
222    pub fn auto_recovery_save_dirty_buffers(&mut self) -> AnyhowResult<usize> {
223        if !self.recovery_service.is_enabled() {
224            return Ok(0);
225        }
226
227        let interval = std::time::Duration::from_secs(
228            self.config.editor.auto_recovery_save_interval_secs as u64,
229        );
230        if self.time_source.elapsed_since(self.last_auto_recovery_save) < interval {
231            return Ok(0);
232        }
233
234        let saved = self.save_pending_recovery_buffers()?;
235        self.last_auto_recovery_save = self.time_source.now();
236        Ok(saved)
237    }
238
239    /// Save all buffers marked `recovery_pending` to recovery storage.
240    /// Shared by the periodic auto-save and the exit flush.
241    fn save_pending_recovery_buffers(&mut self) -> AnyhowResult<usize> {
242        if !self.recovery_service.is_enabled() {
243            return Ok(0);
244        }
245
246        // Collect buffer IDs that need recovery (immutable pass).
247        // Skip composite/hidden buffers — they are not real user content.
248        let buffers_needing_recovery: Vec<_> = self
249            .buffers
250            .iter()
251            .filter_map(|(buffer_id, state)| {
252                if state.is_composite_buffer {
253                    return None;
254                }
255                if let Some(meta) = self.buffer_metadata.get(buffer_id) {
256                    if meta.hidden_from_tabs || meta.is_virtual() {
257                        return None;
258                    }
259                }
260                if state.buffer.is_recovery_pending() {
261                    Some(*buffer_id)
262                } else {
263                    None
264                }
265            })
266            .collect();
267
268        // Ensure unnamed buffers have stable recovery IDs (mutable pass).
269        for buffer_id in &buffers_needing_recovery {
270            let needs_id = self
271                .buffer_metadata
272                .get(buffer_id)
273                .map(|meta| {
274                    let path = meta.file_path();
275                    let is_unnamed = path.map(|p| p.as_os_str().is_empty()).unwrap_or(true);
276                    is_unnamed && meta.recovery_id.is_none()
277                })
278                .unwrap_or(false);
279
280            if needs_id {
281                let new_id = crate::services::recovery::generate_buffer_id();
282                if let Some(meta) = self.buffer_metadata.get_mut(buffer_id) {
283                    meta.recovery_id = Some(new_id);
284                }
285            }
286        }
287
288        // Collect full buffer info with stable recovery IDs.
289        let buffer_info: Vec<_> = buffers_needing_recovery
290            .into_iter()
291            .filter_map(|buffer_id| {
292                let state = self.buffers.get(&buffer_id)?;
293                let meta = self.buffer_metadata.get(&buffer_id)?;
294                let path = state.buffer.file_path().map(|p| p.to_path_buf());
295                let recovery_id = if let Some(ref stored_id) = meta.recovery_id {
296                    stored_id.clone()
297                } else {
298                    self.recovery_service.get_buffer_id(path.as_deref())
299                };
300                let recovery_pending = state.buffer.is_recovery_pending();
301                if self
302                    .recovery_service
303                    .needs_auto_recovery_save(&recovery_id, recovery_pending)
304                {
305                    Some((buffer_id, recovery_id, path))
306                } else {
307                    None
308                }
309            })
310            .collect();
311
312        let mut saved_count = 0;
313        for (buffer_id, recovery_id, path) in buffer_info {
314            if self.save_buffer_to_recovery(&buffer_id, &recovery_id, path.as_deref())? {
315                saved_count += 1;
316            }
317        }
318        Ok(saved_count)
319    }
320
321    /// Check if the active buffer is marked dirty for auto-recovery-save
322    /// Used for testing to verify that edits properly trigger recovery tracking
323    pub fn is_active_buffer_recovery_dirty(&self) -> bool {
324        if let Some(state) = self.buffers.get(&self.active_buffer()) {
325            state.buffer.is_recovery_pending()
326        } else {
327            false
328        }
329    }
330
331    /// Delete recovery for a buffer (call after saving or closing)
332    pub fn delete_buffer_recovery(&mut self, buffer_id: BufferId) -> AnyhowResult<()> {
333        // Get recovery_id: use stored one for unnamed buffers, compute from path otherwise
334        let recovery_id = {
335            let meta = self.buffer_metadata.get(&buffer_id);
336            let state = self.buffers.get(&buffer_id);
337
338            if let Some(stored_id) = meta.and_then(|m| m.recovery_id.clone()) {
339                stored_id
340            } else if let Some(state) = state {
341                let path = state.buffer.file_path().map(|p| p.to_path_buf());
342                self.recovery_service.get_buffer_id(path.as_deref())
343            } else {
344                return Ok(());
345            }
346        };
347
348        self.recovery_service.delete_buffer_recovery(&recovery_id)?;
349
350        // Clear recovery_pending since buffer is now saved
351        if let Some(state) = self.buffers.get_mut(&buffer_id) {
352            state.buffer.set_recovery_pending(false);
353        }
354        Ok(())
355    }
356
357    /// Save a single buffer's content to recovery storage.
358    ///
359    /// For large files, saves only modified chunks (diffs against original).
360    /// For small files / unnamed buffers, saves full content.
361    /// Returns true if a save was performed, false if skipped.
362    fn save_buffer_to_recovery(
363        &mut self,
364        buffer_id: &BufferId,
365        recovery_id: &str,
366        path: Option<&std::path::Path>,
367    ) -> AnyhowResult<bool> {
368        let state = match self.buffers.get_mut(buffer_id) {
369            Some(s) => s,
370            None => return Ok(false),
371        };
372        let line_count = state.buffer.line_count();
373
374        if state.buffer.is_large_file() {
375            let chunks = state.buffer.get_recovery_chunks();
376            if chunks.is_empty() {
377                state.buffer.set_recovery_pending(false);
378                return Ok(false);
379            }
380            let recovery_chunks: Vec<_> = chunks
381                .into_iter()
382                .map(|(offset, content)| {
383                    crate::services::recovery::types::RecoveryChunk::new(offset, 0, content)
384                })
385                .collect();
386            let original_size = state.buffer.original_file_size().unwrap_or(0);
387            let final_size = state.buffer.total_bytes();
388            self.recovery_service.save_buffer(
389                recovery_id,
390                recovery_chunks,
391                path,
392                None,
393                line_count,
394                original_size,
395                final_size,
396            )?;
397        } else {
398            let total_bytes = state.buffer.total_bytes();
399            let content = match state.buffer.get_text_range_mut(0, total_bytes) {
400                Ok(bytes) => bytes,
401                Err(e) => {
402                    tracing::warn!("Failed to get buffer content for recovery save: {}", e);
403                    return Ok(false);
404                }
405            };
406            let chunks = vec![crate::services::recovery::types::RecoveryChunk::new(
407                0, 0, content,
408            )];
409            self.recovery_service.save_buffer(
410                recovery_id,
411                chunks,
412                path,
413                None,
414                line_count,
415                0,
416                total_bytes,
417            )?;
418        }
419
420        state.buffer.set_recovery_pending(false);
421        Ok(true)
422    }
423}