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