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