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    /// Push the buffer's full current content to LSP after an out-of-band
18    /// mutation (hot-exit recovery replay / crash-recovery replay). The
19    /// replay paths edit the buffer directly via `buffer.delete` and
20    /// `buffer.insert`, which don't route through the normal event log
21    /// that also emits `didChange`. If LSP's `didOpen` already fired with
22    /// the on-disk content, the server is left on a stale base and every
23    /// position it returns is offset by the net byte delta.
24    pub(crate) fn sync_lsp_after_recovery_replay(&mut self, buffer_id: BufferId) {
25        let Some(text) = self
26            .buffers
27            .get(&buffer_id)
28            .and_then(|state| state.buffer.to_string())
29        else {
30            return;
31        };
32        let full_change = lsp_types::TextDocumentContentChangeEvent {
33            range: None,
34            range_length: None,
35            text,
36        };
37        self.send_lsp_changes_for_buffer(buffer_id, vec![full_change]);
38    }
39
40    /// Start the recovery session (call on editor startup after recovery check)
41    pub fn start_recovery_session(&mut self) -> AnyhowResult<()> {
42        Ok(self.recovery_service.start_session()?)
43    }
44
45    /// End the recovery session cleanly (call on normal shutdown)
46    pub fn end_recovery_session(&mut self) -> AnyhowResult<()> {
47        let hot_exit = self.config.editor.hot_exit;
48
49        if hot_exit {
50            // Force all modified buffers to be re-saved by marking them pending,
51            // then reuse the existing periodic recovery save logic.
52            for (_, state) in self.buffers.iter_mut() {
53                if state.buffer.is_modified() {
54                    state.buffer.set_recovery_pending(true);
55                }
56            }
57            self.save_pending_recovery_buffers()?;
58
59            // Collect recovery IDs for buffers that should survive this session
60            let preserve_ids = self.recovery_ids_to_preserve();
61            Ok(self
62                .recovery_service
63                .end_session_preserving(&preserve_ids)?)
64        } else {
65            Ok(self.recovery_service.end_session()?)
66        }
67    }
68
69    /// Collect recovery IDs for all buffers that should be preserved across sessions.
70    fn recovery_ids_to_preserve(&self) -> Vec<String> {
71        let hot_exit = self.config.editor.hot_exit;
72
73        self.buffer_metadata
74            .iter()
75            .filter_map(|(buffer_id, meta)| {
76                if meta.hidden_from_tabs || meta.is_virtual() {
77                    return None;
78                }
79                if !hot_exit {
80                    return None;
81                }
82                let state = self.buffers.get(buffer_id)?;
83                if !state.buffer.is_modified() {
84                    return None;
85                }
86                let path = meta.file_path()?;
87                let is_unnamed = path.as_os_str().is_empty();
88                if is_unnamed && state.buffer.total_bytes() == 0 {
89                    return None;
90                }
91                // Use stored recovery_id, or compute from path for file-backed buffers
92                meta.recovery_id.clone().or_else(|| {
93                    let file_path = state.buffer.file_path().map(|p| p.to_path_buf());
94                    Some(self.recovery_service.get_buffer_id(file_path.as_deref()))
95                })
96            })
97            .collect()
98    }
99
100    /// Check if there are files to recover from a crash
101    pub fn has_recovery_files(&self) -> AnyhowResult<bool> {
102        Ok(self.recovery_service.should_offer_recovery()?)
103    }
104
105    /// Get list of recoverable files
106    pub fn list_recoverable_files(
107        &self,
108    ) -> AnyhowResult<Vec<crate::services::recovery::RecoveryEntry>> {
109        Ok(self.recovery_service.list_recoverable()?)
110    }
111
112    /// Recover all buffers from recovery files
113    /// Returns the number of buffers recovered
114    pub fn recover_all_buffers(&mut self) -> AnyhowResult<usize> {
115        use crate::services::recovery::RecoveryResult;
116
117        let entries = self.recovery_service.list_recoverable()?;
118        let mut recovered_count = 0;
119
120        for entry in entries {
121            match self.recovery_service.accept_recovery(&entry) {
122                Ok(RecoveryResult::Recovered {
123                    original_path,
124                    content,
125                }) => {
126                    // Full content recovery (new/small buffers)
127                    let text = String::from_utf8_lossy(&content).into_owned();
128
129                    if let Some(path) = original_path {
130                        // Open the file path (this creates the buffer)
131                        match self.open_file(&path) {
132                            Ok(buffer_id) => {
133                                // Replace buffer content with recovered content
134                                {
135                                    let state = self.active_state_mut();
136                                    let total = state.buffer.total_bytes();
137                                    state.buffer.delete(0..total);
138                                    state.buffer.insert(0, &text);
139                                    // Mark as modified since it differs from disk
140                                    state.buffer.set_modified(true);
141                                }
142                                // Invalidate the event log's saved position so undo
143                                // can't incorrectly clear the modified flag
144                                self.active_event_log_mut().clear_saved_position();
145                                // Recovery replay mutates the buffer directly —
146                                // push the new content to LSP so tokens and
147                                // positions don't drift against an on-disk base.
148                                self.sync_lsp_after_recovery_replay(buffer_id);
149                                recovered_count += 1;
150                                tracing::info!("Recovered buffer: {}", path.display());
151                            }
152                            Err(e) => {
153                                // Check if this is a large file encoding confirmation error
154                                if let Some(confirmation) = e.downcast_ref::<
155                                    crate::model::buffer::LargeFileEncodingConfirmation,
156                                >() {
157                                    self.start_large_file_encoding_confirmation(confirmation);
158                                } else {
159                                    tracing::warn!("Failed to recover buffer {}: {}", path.display(), e);
160                                }
161                            }
162                        }
163                    } else {
164                        // Unsaved buffer - create new buffer with recovered content
165                        let buffer_id = self.new_buffer();
166                        {
167                            let state = self.active_state_mut();
168                            state.buffer.insert(0, &text);
169                            state.buffer.set_modified(true);
170                        }
171                        // Invalidate the event log's saved position so undo
172                        // can't incorrectly clear the modified flag
173                        self.active_event_log_mut().clear_saved_position();
174                        self.sync_lsp_after_recovery_replay(buffer_id);
175                        recovered_count += 1;
176                        tracing::info!("Recovered unsaved buffer");
177                    }
178                }
179                Ok(RecoveryResult::RecoveredChunks {
180                    original_path,
181                    chunks,
182                }) => {
183                    // Chunked recovery for large files - apply chunks directly
184                    if let Ok(buffer_id) = self.open_file(&original_path) {
185                        {
186                            let state = self.active_state_mut();
187
188                            // Apply chunks in reverse order to preserve offsets
189                            // Each chunk: delete original_len bytes at offset, then insert content
190                            for chunk in chunks.into_iter().rev() {
191                                let text = String::from_utf8_lossy(&chunk.content).into_owned();
192                                if chunk.original_len > 0 {
193                                    state
194                                        .buffer
195                                        .delete(chunk.offset..chunk.offset + chunk.original_len);
196                                }
197                                state.buffer.insert(chunk.offset, &text);
198                            }
199
200                            // Mark as modified since it differs from disk
201                            state.buffer.set_modified(true);
202                        }
203                        // Invalidate the event log's saved position so undo
204                        // can't incorrectly clear the modified flag
205                        self.active_event_log_mut().clear_saved_position();
206                        self.sync_lsp_after_recovery_replay(buffer_id);
207                        recovered_count += 1;
208                        tracing::info!("Recovered buffer with chunks: {}", original_path.display());
209                    }
210                }
211                Ok(RecoveryResult::OriginalFileModified { id, original_path }) => {
212                    tracing::warn!(
213                        "Recovery file {} skipped: original file {} was modified",
214                        id,
215                        original_path.display()
216                    );
217                    // Keep the recovery file so the user can manually inspect it.
218                    // Show a warning so the user knows unsaved changes exist.
219                    let name = original_path
220                        .file_name()
221                        .unwrap_or_default()
222                        .to_string_lossy();
223                    self.set_status_message(format!(
224                        "{} changed on disk; unsaved changes not restored",
225                        name
226                    ));
227                }
228                Ok(RecoveryResult::Corrupted { id, reason }) => {
229                    tracing::warn!("Recovery file {} corrupted: {}", id, reason);
230                }
231                Ok(RecoveryResult::NotFound { id }) => {
232                    tracing::warn!("Recovery file {} not found", id);
233                }
234                Err(e) => {
235                    tracing::warn!("Failed to recover {}: {}", entry.id, e);
236                }
237            }
238        }
239
240        Ok(recovered_count)
241    }
242
243    /// Discard all recovery files (user decided not to recover)
244    /// Returns the number of recovery files deleted
245    pub fn discard_all_recovery(&mut self) -> AnyhowResult<usize> {
246        Ok(self.recovery_service.discard_all_recovery()?)
247    }
248
249    /// Restore only the hot-exit content from the previous clean exit:
250    /// files with unsaved modifications and unnamed buffers that held
251    /// content.  Called when full session restore is opted out (via
252    /// `--no-restore` or `editor.restore_previous_session = false`) so
253    /// the user does not lose in-progress work just because they asked
254    /// to skip restoring the workspace layout.
255    ///
256    /// Unlike [`Editor::recover_all_buffers`], this path uses
257    /// `load_recovery` and leaves the recovery files in place so the
258    /// current session's hot-exit pipeline keeps owning them (the files
259    /// are cleaned up on the next clean shutdown via
260    /// `end_session_preserving`).
261    ///
262    /// Returns the number of buffers restored.
263    pub fn try_restore_hot_exit_buffers(&mut self) -> AnyhowResult<usize> {
264        use crate::services::recovery::RecoveryResult;
265
266        if !self.config.editor.hot_exit {
267            return Ok(0);
268        }
269
270        let entries = self.recovery_service.list_recoverable()?;
271        if entries.is_empty() {
272            return Ok(0);
273        }
274
275        let mut restored = 0;
276        for entry in entries {
277            match self.recovery_service.load_recovery(&entry) {
278                Ok(RecoveryResult::Recovered {
279                    original_path,
280                    content,
281                }) => {
282                    let text = String::from_utf8_lossy(&content).into_owned();
283                    if let Some(path) = original_path {
284                        match self.open_file(&path) {
285                            Ok(buffer_id) => {
286                                {
287                                    let state = self.active_state_mut();
288                                    let total = state.buffer.total_bytes();
289                                    state.buffer.delete(0..total);
290                                    state.buffer.insert(0, &text);
291                                    state.buffer.set_modified(true);
292                                    state.buffer.set_recovery_pending(false);
293                                }
294                                self.active_event_log_mut().clear_saved_position();
295                                self.sync_lsp_after_recovery_replay(buffer_id);
296                                restored += 1;
297                                tracing::info!(
298                                    "Hot-exit restore: reopened {} with unsaved changes",
299                                    path.display()
300                                );
301                            }
302                            Err(e) => {
303                                if let Some(confirmation) = e.downcast_ref::<
304                                    crate::model::buffer::LargeFileEncodingConfirmation,
305                                >() {
306                                    self.start_large_file_encoding_confirmation(confirmation);
307                                } else {
308                                    tracing::warn!(
309                                        "Hot-exit restore failed to open {}: {}",
310                                        path.display(),
311                                        e
312                                    );
313                                }
314                            }
315                        }
316                    } else {
317                        // Unnamed buffer with content — create a fresh
318                        // buffer, drop the recovery ID into metadata so
319                        // future hot-exit saves hit the same file.
320                        let buffer_id = self.new_buffer();
321                        {
322                            let state = self.active_state_mut();
323                            state.buffer.insert(0, &text);
324                            state.buffer.set_modified(true);
325                            state.buffer.set_recovery_pending(false);
326                        }
327                        self.active_event_log_mut().clear_saved_position();
328                        if let Some(meta) = self.buffer_metadata.get_mut(&buffer_id) {
329                            meta.recovery_id = Some(entry.id.clone());
330                        }
331                        self.sync_lsp_after_recovery_replay(buffer_id);
332                        restored += 1;
333                        tracing::info!(
334                            "Hot-exit restore: reopened unnamed buffer (recovery_id={})",
335                            entry.id
336                        );
337                    }
338                }
339                Ok(RecoveryResult::RecoveredChunks {
340                    original_path,
341                    chunks,
342                }) => match self.open_file(&original_path) {
343                    Ok(buffer_id) => {
344                        {
345                            let state = self.active_state_mut();
346                            for chunk in chunks.into_iter().rev() {
347                                let text = String::from_utf8_lossy(&chunk.content).into_owned();
348                                if chunk.original_len > 0 {
349                                    state
350                                        .buffer
351                                        .delete(chunk.offset..chunk.offset + chunk.original_len);
352                                }
353                                state.buffer.insert(chunk.offset, &text);
354                            }
355                            state.buffer.set_modified(true);
356                            state.buffer.set_recovery_pending(false);
357                        }
358                        self.active_event_log_mut().clear_saved_position();
359                        self.sync_lsp_after_recovery_replay(buffer_id);
360                        restored += 1;
361                        tracing::info!(
362                            "Hot-exit restore: reopened {} with chunked changes",
363                            original_path.display()
364                        );
365                    }
366                    Err(e) => {
367                        tracing::warn!(
368                            "Hot-exit restore failed to open {}: {}",
369                            original_path.display(),
370                            e
371                        );
372                    }
373                },
374                Ok(RecoveryResult::OriginalFileModified { id, original_path }) => {
375                    tracing::warn!(
376                        "Hot-exit restore skipped {}: original file {} changed on disk",
377                        id,
378                        original_path.display()
379                    );
380                }
381                Ok(RecoveryResult::Corrupted { id, reason }) => {
382                    tracing::warn!("Hot-exit restore skipped {}: corrupted ({})", id, reason);
383                }
384                Ok(RecoveryResult::NotFound { id }) => {
385                    tracing::warn!("Hot-exit restore: recovery file {} missing", id);
386                }
387                Err(e) => {
388                    tracing::warn!("Hot-exit restore: failed to load {}: {}", entry.id, e);
389                }
390            }
391        }
392
393        Ok(restored)
394    }
395
396    /// Perform auto-recovery-save for all modified buffers if needed.
397    /// Called frequently (every frame); rate-limited by `auto_recovery_save_interval_secs`.
398    pub fn auto_recovery_save_dirty_buffers(&mut self) -> AnyhowResult<usize> {
399        if !self.recovery_service.is_enabled() {
400            return Ok(0);
401        }
402
403        let interval = std::time::Duration::from_secs(
404            self.config.editor.auto_recovery_save_interval_secs as u64,
405        );
406        if self.time_source.elapsed_since(self.last_auto_recovery_save) < interval {
407            return Ok(0);
408        }
409
410        let saved = self.save_pending_recovery_buffers()?;
411        self.last_auto_recovery_save = self.time_source.now();
412        Ok(saved)
413    }
414
415    /// Save all buffers marked `recovery_pending` to recovery storage.
416    /// Shared by the periodic auto-save and the exit flush.
417    fn save_pending_recovery_buffers(&mut self) -> AnyhowResult<usize> {
418        if !self.recovery_service.is_enabled() {
419            return Ok(0);
420        }
421
422        // Collect buffer IDs that need recovery (immutable pass).
423        // Skip composite/hidden buffers — they are not real user content.
424        let buffers_needing_recovery: Vec<_> = self
425            .buffers
426            .iter()
427            .filter_map(|(buffer_id, state)| {
428                if state.is_composite_buffer {
429                    return None;
430                }
431                if let Some(meta) = self.buffer_metadata.get(buffer_id) {
432                    if meta.hidden_from_tabs || meta.is_virtual() {
433                        return None;
434                    }
435                }
436                if state.buffer.is_recovery_pending() {
437                    Some(*buffer_id)
438                } else {
439                    None
440                }
441            })
442            .collect();
443
444        // Ensure unnamed buffers have stable recovery IDs (mutable pass).
445        for buffer_id in &buffers_needing_recovery {
446            let needs_id = self
447                .buffer_metadata
448                .get(buffer_id)
449                .map(|meta| {
450                    let path = meta.file_path();
451                    let is_unnamed = path.map(|p| p.as_os_str().is_empty()).unwrap_or(true);
452                    is_unnamed && meta.recovery_id.is_none()
453                })
454                .unwrap_or(false);
455
456            if needs_id {
457                let new_id = crate::services::recovery::generate_buffer_id();
458                if let Some(meta) = self.buffer_metadata.get_mut(buffer_id) {
459                    meta.recovery_id = Some(new_id);
460                }
461            }
462        }
463
464        // Collect full buffer info with stable recovery IDs.
465        let buffer_info: Vec<_> = buffers_needing_recovery
466            .into_iter()
467            .filter_map(|buffer_id| {
468                let state = self.buffers.get(&buffer_id)?;
469                let meta = self.buffer_metadata.get(&buffer_id)?;
470                let path = state.buffer.file_path().map(|p| p.to_path_buf());
471                let recovery_id = if let Some(ref stored_id) = meta.recovery_id {
472                    stored_id.clone()
473                } else {
474                    self.recovery_service.get_buffer_id(path.as_deref())
475                };
476                let recovery_pending = state.buffer.is_recovery_pending();
477                if self
478                    .recovery_service
479                    .needs_auto_recovery_save(&recovery_id, recovery_pending)
480                {
481                    Some((buffer_id, recovery_id, path))
482                } else {
483                    None
484                }
485            })
486            .collect();
487
488        let mut saved_count = 0;
489        for (buffer_id, recovery_id, path) in buffer_info {
490            if self.save_buffer_to_recovery(&buffer_id, &recovery_id, path.as_deref())? {
491                saved_count += 1;
492            }
493        }
494        Ok(saved_count)
495    }
496
497    /// Check if the active buffer is marked dirty for auto-recovery-save
498    /// Used for testing to verify that edits properly trigger recovery tracking
499    pub fn is_active_buffer_recovery_dirty(&self) -> bool {
500        if let Some(state) = self.buffers.get(&self.active_buffer()) {
501            state.buffer.is_recovery_pending()
502        } else {
503            false
504        }
505    }
506
507    /// Delete recovery for a buffer (call after saving or closing)
508    pub fn delete_buffer_recovery(&mut self, buffer_id: BufferId) -> AnyhowResult<()> {
509        // Get recovery_id: use stored one for unnamed buffers, compute from path otherwise
510        let recovery_id = {
511            let meta = self.buffer_metadata.get(&buffer_id);
512            let state = self.buffers.get(&buffer_id);
513
514            if let Some(stored_id) = meta.and_then(|m| m.recovery_id.clone()) {
515                stored_id
516            } else if let Some(state) = state {
517                let path = state.buffer.file_path().map(|p| p.to_path_buf());
518                self.recovery_service.get_buffer_id(path.as_deref())
519            } else {
520                return Ok(());
521            }
522        };
523
524        self.recovery_service.delete_buffer_recovery(&recovery_id)?;
525
526        // Clear recovery_pending since buffer is now saved
527        if let Some(state) = self.buffers.get_mut(&buffer_id) {
528            state.buffer.set_recovery_pending(false);
529        }
530        Ok(())
531    }
532
533    /// Save a single buffer's content to recovery storage.
534    ///
535    /// For large files, saves only modified chunks (diffs against original).
536    /// For small files / unnamed buffers, saves full content.
537    /// Returns true if a save was performed, false if skipped.
538    fn save_buffer_to_recovery(
539        &mut self,
540        buffer_id: &BufferId,
541        recovery_id: &str,
542        path: Option<&std::path::Path>,
543    ) -> AnyhowResult<bool> {
544        let state = match self.buffers.get_mut(buffer_id) {
545            Some(s) => s,
546            None => return Ok(false),
547        };
548        let line_count = state.buffer.line_count();
549
550        if state.buffer.is_large_file() {
551            let chunks = state.buffer.get_recovery_chunks();
552            if chunks.is_empty() {
553                state.buffer.set_recovery_pending(false);
554                return Ok(false);
555            }
556            let recovery_chunks: Vec<_> = chunks
557                .into_iter()
558                .map(|(offset, content)| {
559                    crate::services::recovery::types::RecoveryChunk::new(offset, 0, content)
560                })
561                .collect();
562            let original_size = state.buffer.original_file_size().unwrap_or(0);
563            let final_size = state.buffer.total_bytes();
564            self.recovery_service.save_buffer(
565                recovery_id,
566                recovery_chunks,
567                path,
568                None,
569                line_count,
570                original_size,
571                final_size,
572            )?;
573        } else {
574            let total_bytes = state.buffer.total_bytes();
575            let content = match state.buffer.get_text_range_mut(0, total_bytes) {
576                Ok(bytes) => bytes,
577                Err(e) => {
578                    tracing::warn!("Failed to get buffer content for recovery save: {}", e);
579                    return Ok(false);
580                }
581            };
582            let chunks = vec![crate::services::recovery::types::RecoveryChunk::new(
583                0, 0, content,
584            )];
585            self.recovery_service.save_buffer(
586                recovery_id,
587                chunks,
588                path,
589                None,
590                line_count,
591                0,
592                total_bytes,
593            )?;
594        }
595
596        state.buffer.set_recovery_pending(false);
597        Ok(true)
598    }
599}