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