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