Skip to main content

fresh/app/
workspace.rs

1//! Workspace persistence integration for the Editor
2//!
3//! This module provides conversion between live Editor state and serialized Workspace data.
4//!
5//! # Role in Incremental Streaming Architecture
6//!
7//! This module handles workspace save/restore for terminals.
8//! See `crate::services::terminal` for the full architecture diagram.
9//!
10//! ## Workspace Save
11//!
12//! [`Editor::save_workspace`] calls [`Editor::sync_all_terminal_backing_files`] to ensure
13//! all terminal backing files contain complete state (scrollback + visible screen)
14//! before serializing workspace metadata.
15//!
16//! ## Workspace Restore
17//!
18//! [`Editor::restore_terminal_from_workspace`] loads the backing file directly as a
19//! read-only buffer, skipping the expensive log replay. The user starts in scrollback
20//! mode viewing the last workspace state. A new PTY is spawned when they re-enter
21//! terminal mode.
22//!
23//! Performance: O(1) ≈ 10ms (lazy load) vs O(n) ≈ 1000ms (log replay)
24
25use std::collections::{HashMap, HashSet};
26use std::path::{Path, PathBuf};
27use std::time::Instant;
28
29use crate::state::EditorState;
30
31use crate::model::event::{BufferId, LeafId, SplitDirection, SplitId};
32use crate::services::terminal::TerminalId;
33use crate::state::ViewMode;
34use crate::view::split::{SplitNode, SplitViewState};
35use crate::workspace::{
36    FileExplorerState, PersistedFileWorkspace, SearchOptions, SerializedBookmark, SerializedCursor,
37    SerializedFileState, SerializedFoldRange, SerializedScroll, SerializedSplitDirection,
38    SerializedSplitNode, SerializedSplitViewState, SerializedTabRef, SerializedTerminalWorkspace,
39    SerializedViewMode, UnnamedBufferRef, Workspace, WorkspaceConfigOverrides, WorkspaceError,
40    WorkspaceHistories, WORKSPACE_VERSION,
41};
42
43use super::types::Bookmark;
44use super::Editor;
45
46/// Workspace persistence state tracker
47///
48/// Tracks dirty state and handles debounced saving for crash resistance.
49pub struct WorkspaceTracker {
50    /// Whether workspace has unsaved changes
51    dirty: bool,
52    /// Last save time
53    last_save: Instant,
54    /// Minimum interval between saves (debounce)
55    save_interval: std::time::Duration,
56    /// Whether workspace persistence is enabled
57    enabled: bool,
58}
59
60impl WorkspaceTracker {
61    /// Create a new workspace tracker
62    pub fn new(enabled: bool) -> Self {
63        Self {
64            dirty: false,
65            last_save: Instant::now(),
66            save_interval: std::time::Duration::from_secs(5),
67            enabled,
68        }
69    }
70
71    /// Check if workspace tracking is enabled
72    pub fn is_enabled(&self) -> bool {
73        self.enabled
74    }
75
76    /// Mark workspace as needing save
77    pub fn mark_dirty(&mut self) {
78        if self.enabled {
79            self.dirty = true;
80        }
81    }
82
83    /// Check if a save is needed and enough time has passed
84    pub fn should_save(&self) -> bool {
85        self.enabled && self.dirty && self.last_save.elapsed() >= self.save_interval
86    }
87
88    /// Record that a save was performed
89    pub fn record_save(&mut self) {
90        self.dirty = false;
91        self.last_save = Instant::now();
92    }
93
94    /// Check if there are unsaved changes (for shutdown)
95    pub fn is_dirty(&self) -> bool {
96        self.dirty
97    }
98}
99
100impl Editor {
101    /// Capture current editor state into a Workspace
102    pub fn capture_workspace(&self) -> Workspace {
103        tracing::debug!("Capturing workspace for {:?}", self.working_dir);
104
105        // Collect terminal metadata for workspace restore
106        let mut terminals = Vec::new();
107        let mut terminal_indices: HashMap<TerminalId, usize> = HashMap::new();
108        let mut seen = HashSet::new();
109        for terminal_id in self.terminal_buffers.values().copied() {
110            if seen.insert(terminal_id) {
111                let idx = terminals.len();
112                terminal_indices.insert(terminal_id, idx);
113                let handle = self.terminal_manager.get(terminal_id);
114                let (cols, rows) = handle
115                    .map(|h| h.size())
116                    .unwrap_or((self.terminal_width, self.terminal_height));
117                let cwd = handle.and_then(|h| h.cwd());
118                let shell = handle
119                    .map(|h| h.shell().to_string())
120                    .unwrap_or_else(crate::services::terminal::detect_shell);
121                let log_path = self
122                    .terminal_log_files
123                    .get(&terminal_id)
124                    .cloned()
125                    .unwrap_or_else(|| {
126                        let root = self.dir_context.terminal_dir_for(&self.working_dir);
127                        root.join(format!("fresh-terminal-{}.log", terminal_id.0))
128                    });
129                let backing_path = self
130                    .terminal_backing_files
131                    .get(&terminal_id)
132                    .cloned()
133                    .unwrap_or_else(|| {
134                        let root = self.dir_context.terminal_dir_for(&self.working_dir);
135                        root.join(format!("fresh-terminal-{}.txt", terminal_id.0))
136                    });
137
138                terminals.push(SerializedTerminalWorkspace {
139                    terminal_index: idx,
140                    cwd,
141                    shell,
142                    cols,
143                    rows,
144                    log_path,
145                    backing_path,
146                });
147            }
148        }
149
150        let split_layout = serialize_split_node(
151            self.split_manager.root(),
152            &self.buffer_metadata,
153            &self.working_dir,
154            &self.terminal_buffers,
155            &terminal_indices,
156            self.split_manager.labels(),
157        );
158
159        // Build a map of leaf_id -> active_buffer_id from the split tree
160        // This tells us which buffer's cursor/scroll to save for each split
161        let active_buffers: HashMap<LeafId, BufferId> = self
162            .split_manager
163            .root()
164            .get_leaves_with_rects(ratatui::layout::Rect::default())
165            .into_iter()
166            .map(|(leaf_id, buffer_id, _)| (leaf_id, buffer_id))
167            .collect();
168
169        let mut split_states = HashMap::new();
170        for (leaf_id, view_state) in &self.split_view_states {
171            let active_buffer = active_buffers.get(leaf_id).copied();
172            let serialized = serialize_split_view_state(
173                view_state,
174                &self.buffers,
175                &self.buffer_metadata,
176                &self.working_dir,
177                active_buffer,
178                &self.terminal_buffers,
179                &terminal_indices,
180            );
181            tracing::trace!(
182                "Split {:?}: {} open tabs, active_buffer={:?}",
183                leaf_id,
184                serialized.open_tabs.len(),
185                active_buffer
186            );
187            split_states.insert(leaf_id.0 .0, serialized);
188        }
189
190        tracing::debug!(
191            "Captured {} split states, active_split={}",
192            split_states.len(),
193            SplitId::from(self.split_manager.active_split()).0
194        );
195
196        // Capture file explorer state
197        let file_explorer = if let Some(ref explorer) = self.file_explorer {
198            // Get expanded directories from the tree
199            let expanded_dirs = get_expanded_dirs(explorer, &self.working_dir);
200            FileExplorerState {
201                visible: self.file_explorer_visible,
202                width_percent: self.file_explorer_width_percent,
203                expanded_dirs,
204                scroll_offset: explorer.get_scroll_offset(),
205                show_hidden: explorer.ignore_patterns().show_hidden(),
206                show_gitignored: explorer.ignore_patterns().show_gitignored(),
207            }
208        } else {
209            FileExplorerState {
210                visible: self.file_explorer_visible,
211                width_percent: self.file_explorer_width_percent,
212                expanded_dirs: Vec::new(),
213                scroll_offset: 0,
214                show_hidden: false,
215                show_gitignored: false,
216            }
217        };
218
219        // Capture config overrides (only store deviations from defaults)
220        let config_overrides = WorkspaceConfigOverrides {
221            line_numbers: Some(self.config.editor.line_numbers),
222            relative_line_numbers: Some(self.config.editor.relative_line_numbers),
223            line_wrap: Some(self.config.editor.line_wrap),
224            syntax_highlighting: Some(self.config.editor.syntax_highlighting),
225            enable_inlay_hints: Some(self.config.editor.enable_inlay_hints),
226            mouse_enabled: Some(self.mouse_enabled),
227            menu_bar_hidden: Some(!self.menu_bar_visible),
228        };
229
230        // Capture histories using the items() accessor from the prompt_histories HashMap
231        let histories = WorkspaceHistories {
232            search: self
233                .prompt_histories
234                .get("search")
235                .map(|h| h.items().to_vec())
236                .unwrap_or_default(),
237            replace: self
238                .prompt_histories
239                .get("replace")
240                .map(|h| h.items().to_vec())
241                .unwrap_or_default(),
242            command_palette: Vec::new(), // Future: when command palette has history
243            goto_line: self
244                .prompt_histories
245                .get("goto_line")
246                .map(|h| h.items().to_vec())
247                .unwrap_or_default(),
248            open_file: Vec::new(), // Future: when file open prompt has history
249        };
250        tracing::trace!(
251            "Captured histories: {} search, {} replace",
252            histories.search.len(),
253            histories.replace.len()
254        );
255
256        // Capture search options
257        let search_options = SearchOptions {
258            case_sensitive: self.search_case_sensitive,
259            whole_word: self.search_whole_word,
260            use_regex: self.search_use_regex,
261            confirm_each: self.search_confirm_each,
262        };
263
264        // Capture bookmarks
265        let bookmarks =
266            serialize_bookmarks(&self.bookmarks, &self.buffer_metadata, &self.working_dir);
267
268        // Capture external files (files outside working_dir)
269        // These are stored as absolute paths since they can't be made relative
270        let external_files: Vec<PathBuf> = self
271            .buffer_metadata
272            .values()
273            .filter_map(|meta| meta.file_path())
274            .filter(|abs_path| abs_path.strip_prefix(&self.working_dir).is_err())
275            .cloned()
276            .collect();
277        if !external_files.is_empty() {
278            tracing::debug!("Captured {} external files", external_files.len());
279        }
280
281        // Capture unnamed buffer references (for hot_exit)
282        let unnamed_buffers: Vec<UnnamedBufferRef> = if self.config.editor.hot_exit {
283            self.buffer_metadata
284                .iter()
285                .filter_map(|(buffer_id, meta)| {
286                    // Only file-backed buffers with empty path (unnamed)
287                    let path = meta.file_path()?;
288                    if !path.as_os_str().is_empty() {
289                        return None;
290                    }
291                    // Skip composite/hidden buffers
292                    if meta.hidden_from_tabs || meta.is_virtual() {
293                        return None;
294                    }
295                    // Skip if buffer has no content
296                    let state = self.buffers.get(buffer_id)?;
297                    if state.buffer.total_bytes() == 0 {
298                        return None;
299                    }
300                    // Get or generate recovery ID
301                    let recovery_id = meta.recovery_id.clone()?;
302                    Some(UnnamedBufferRef {
303                        recovery_id,
304                        display_name: meta.display_name.clone(),
305                    })
306                })
307                .collect()
308        } else {
309            Vec::new()
310        };
311        if !unnamed_buffers.is_empty() {
312            tracing::debug!("Captured {} unnamed buffers", unnamed_buffers.len());
313        }
314
315        Workspace {
316            version: WORKSPACE_VERSION,
317            working_dir: self.working_dir.clone(),
318            split_layout,
319            active_split_id: SplitId::from(self.split_manager.active_split()).0,
320            split_states,
321            config_overrides,
322            file_explorer,
323            histories,
324            search_options,
325            bookmarks,
326            terminals,
327            external_files,
328            unnamed_buffers,
329            plugin_global_state: self.plugin_global_state.clone(),
330            saved_at: std::time::SystemTime::now()
331                .duration_since(std::time::UNIX_EPOCH)
332                .unwrap_or_default()
333                .as_secs(),
334        }
335    }
336
337    /// Save the current workspace to disk
338    ///
339    /// Ensures all active terminals have their visible screen synced to
340    /// backing files before capturing the workspace.
341    /// Also saves global file states (scroll/cursor positions per file).
342    pub fn save_workspace(&mut self) -> Result<(), WorkspaceError> {
343        // Ensure all terminal backing files have complete state before saving
344        self.sync_all_terminal_backing_files();
345
346        // Save global file states for all open file buffers
347        self.save_all_global_file_states();
348
349        let workspace = self.capture_workspace();
350
351        // For named sessions, save to session-scoped workspace file
352        if let Some(ref session_name) = self.session_name {
353            workspace.save_session(session_name)
354        } else {
355            workspace.save()
356        }
357    }
358
359    /// Save global file states for all open file buffers
360    fn save_all_global_file_states(&self) {
361        // Collect all file states from all splits
362        for (leaf_id, view_state) in &self.split_view_states {
363            // Get the active buffer for this split
364            let active_buffer = self
365                .split_manager
366                .root()
367                .get_leaves_with_rects(ratatui::layout::Rect::default())
368                .into_iter()
369                .find(|(sid, _, _)| *sid == *leaf_id)
370                .map(|(_, buffer_id, _)| buffer_id);
371
372            if let Some(buffer_id) = active_buffer {
373                self.save_buffer_file_state(buffer_id, view_state);
374            }
375        }
376    }
377
378    /// Save file state for a specific buffer (used when closing files and saving workspace)
379    fn save_buffer_file_state(&self, buffer_id: BufferId, view_state: &SplitViewState) {
380        // Get the file path for this buffer
381        let abs_path = match self.buffer_metadata.get(&buffer_id) {
382            Some(metadata) => match metadata.file_path() {
383                Some(path) => path.to_path_buf(),
384                None => return, // Not a file buffer
385            },
386            None => return,
387        };
388
389        // Capture the current state
390        let primary_cursor = view_state.cursors.primary();
391        let file_state = SerializedFileState {
392            cursor: SerializedCursor {
393                position: primary_cursor.position,
394                anchor: primary_cursor.anchor,
395                sticky_column: primary_cursor.sticky_column,
396            },
397            additional_cursors: view_state
398                .cursors
399                .iter()
400                .skip(1)
401                .map(|(_, cursor)| SerializedCursor {
402                    position: cursor.position,
403                    anchor: cursor.anchor,
404                    sticky_column: cursor.sticky_column,
405                })
406                .collect(),
407            scroll: SerializedScroll {
408                top_byte: view_state.viewport.top_byte,
409                top_view_line_offset: view_state.viewport.top_view_line_offset,
410                left_column: view_state.viewport.left_column,
411            },
412            view_mode: Default::default(),
413            compose_width: None,
414            plugin_state: std::collections::HashMap::new(),
415            folds: Vec::new(),
416        };
417
418        // Save to disk immediately
419        PersistedFileWorkspace::save(&abs_path, file_state);
420    }
421
422    /// Sync all active terminal visible screens to their backing files.
423    ///
424    /// Called before workspace save to ensure backing files contain complete
425    /// terminal state (scrollback + visible screen).
426    fn sync_all_terminal_backing_files(&mut self) {
427        use std::io::BufWriter;
428
429        // Collect terminal IDs and their backing paths
430        let terminals_to_sync: Vec<_> = self
431            .terminal_buffers
432            .values()
433            .copied()
434            .filter_map(|terminal_id| {
435                self.terminal_backing_files
436                    .get(&terminal_id)
437                    .map(|path| (terminal_id, path.clone()))
438            })
439            .collect();
440
441        for (terminal_id, backing_path) in terminals_to_sync {
442            if let Some(handle) = self.terminal_manager.get(terminal_id) {
443                if let Ok(state) = handle.state.lock() {
444                    // Append visible screen to backing file
445                    if let Ok(mut file) = self.filesystem.open_file_for_append(&backing_path) {
446                        let mut writer = BufWriter::new(&mut *file);
447                        if let Err(e) = state.append_visible_screen(&mut writer) {
448                            tracing::warn!(
449                                "Failed to sync terminal {:?} to backing file: {}",
450                                terminal_id,
451                                e
452                            );
453                        }
454                    }
455                }
456            }
457        }
458    }
459
460    /// Try to load and apply a workspace for the current working directory
461    ///
462    /// Returns true if a workspace was successfully loaded and applied.
463    pub fn try_restore_workspace(&mut self) -> Result<bool, WorkspaceError> {
464        tracing::debug!("Attempting to restore workspace for {:?}", self.working_dir);
465
466        // For named sessions, load from session-scoped workspace file
467        let workspace = if let Some(ref session_name) = self.session_name {
468            Workspace::load_session(session_name, &self.working_dir)?
469        } else {
470            Workspace::load(&self.working_dir)?
471        };
472
473        match workspace {
474            Some(workspace) => {
475                tracing::info!("Found workspace, applying...");
476                self.apply_workspace(&workspace)?;
477                Ok(true)
478            }
479            None => {
480                tracing::debug!("No workspace found for {:?}", self.working_dir);
481                Ok(false)
482            }
483        }
484    }
485
486    /// Apply hot exit recovery to all currently open file-backed buffers.
487    ///
488    /// This restores unsaved changes from recovery files for buffers that were
489    /// opened via CLI (without workspace restore). Returns the number of buffers
490    /// recovered.
491    pub fn apply_hot_exit_recovery(&mut self) -> anyhow::Result<usize> {
492        if !self.config.editor.hot_exit {
493            return Ok(0);
494        }
495
496        let entries = self.recovery_service.list_recoverable()?;
497        if entries.is_empty() {
498            return Ok(0);
499        }
500
501        // Collect buffer IDs and their file paths
502        let buffer_files: Vec<_> = self
503            .buffers
504            .iter()
505            .filter_map(|(buffer_id, state)| {
506                let path = state.buffer.file_path()?.to_path_buf();
507                if path.as_os_str().is_empty() {
508                    return None; // Skip unnamed buffers
509                }
510                Some((*buffer_id, path))
511            })
512            .collect();
513
514        let mut recovered = 0;
515        for (buffer_id, file_path) in buffer_files {
516            let recovery_id = self.recovery_service.get_buffer_id(Some(&file_path));
517            let entry = entries.iter().find(|e| e.id == recovery_id);
518            if let Some(entry) = entry {
519                match self.recovery_service.load_recovery(entry) {
520                    Ok(crate::services::recovery::RecoveryResult::Recovered {
521                        content, ..
522                    }) => {
523                        if let Some(state) = self.buffers.get_mut(&buffer_id) {
524                            let current_len = state.buffer.total_bytes();
525                            let text = String::from_utf8_lossy(&content).into_owned();
526                            let current = state.buffer.get_text_range_mut(0, current_len).ok();
527                            let current_text = current
528                                .as_ref()
529                                .map(|b| String::from_utf8_lossy(b).into_owned());
530                            if current_text.as_deref() != Some(&text) {
531                                state.buffer.delete(0..current_len);
532                                state.buffer.insert(0, &text);
533                                state.buffer.set_modified(true);
534                                state.buffer.set_recovery_pending(false);
535                                // Invalidate saved position so undo can't
536                                // incorrectly clear the modified flag
537                                if let Some(log) = self.event_logs.get_mut(&buffer_id) {
538                                    log.clear_saved_position();
539                                }
540                                recovered += 1;
541                                tracing::info!(
542                                    "Restored unsaved changes for {:?} from hot exit recovery",
543                                    file_path
544                                );
545                            }
546                        }
547                    }
548                    Ok(crate::services::recovery::RecoveryResult::RecoveredChunks {
549                        chunks,
550                        ..
551                    }) => {
552                        if let Some(state) = self.buffers.get_mut(&buffer_id) {
553                            for chunk in chunks.into_iter().rev() {
554                                let text = String::from_utf8_lossy(&chunk.content).into_owned();
555                                if chunk.original_len > 0 {
556                                    state
557                                        .buffer
558                                        .delete(chunk.offset..chunk.offset + chunk.original_len);
559                                }
560                                state.buffer.insert(chunk.offset, &text);
561                            }
562                            state.buffer.set_modified(true);
563                            state.buffer.set_recovery_pending(false);
564                            // Invalidate saved position so undo can't
565                            // incorrectly clear the modified flag
566                            if let Some(log) = self.event_logs.get_mut(&buffer_id) {
567                                log.clear_saved_position();
568                            }
569                            recovered += 1;
570                            tracing::info!(
571                                "Restored unsaved changes (chunked) for {:?} from hot exit recovery",
572                                file_path
573                            );
574                        }
575                    }
576                    Ok(crate::services::recovery::RecoveryResult::OriginalFileModified {
577                        original_path,
578                        ..
579                    }) => {
580                        let name = original_path
581                            .file_name()
582                            .unwrap_or_default()
583                            .to_string_lossy();
584                        tracing::warn!("{} changed on disk; unsaved changes not restored", name);
585                        self.set_status_message(format!(
586                            "{} changed on disk; unsaved changes not restored",
587                            name
588                        ));
589                    }
590                    Ok(_) => {} // Corrupted, NotFound - skip
591                    Err(e) => {
592                        tracing::debug!(
593                            "Failed to load hot exit recovery for {:?}: {}",
594                            file_path,
595                            e
596                        );
597                    }
598                }
599            }
600        }
601
602        Ok(recovered)
603    }
604
605    /// Apply a loaded workspace to the editor
606    pub fn apply_workspace(&mut self, workspace: &Workspace) -> Result<(), WorkspaceError> {
607        tracing::debug!(
608            "Applying workspace with {} split states",
609            workspace.split_states.len()
610        );
611
612        // 1. Apply config overrides
613        if let Some(line_numbers) = workspace.config_overrides.line_numbers {
614            self.config.editor.line_numbers = line_numbers;
615        }
616        if let Some(relative_line_numbers) = workspace.config_overrides.relative_line_numbers {
617            self.config.editor.relative_line_numbers = relative_line_numbers;
618        }
619        if let Some(line_wrap) = workspace.config_overrides.line_wrap {
620            self.config.editor.line_wrap = line_wrap;
621        }
622        if let Some(syntax_highlighting) = workspace.config_overrides.syntax_highlighting {
623            self.config.editor.syntax_highlighting = syntax_highlighting;
624        }
625        if let Some(enable_inlay_hints) = workspace.config_overrides.enable_inlay_hints {
626            self.config.editor.enable_inlay_hints = enable_inlay_hints;
627        }
628        if let Some(mouse_enabled) = workspace.config_overrides.mouse_enabled {
629            self.mouse_enabled = mouse_enabled;
630        }
631        if let Some(menu_bar_hidden) = workspace.config_overrides.menu_bar_hidden {
632            self.menu_bar_visible = !menu_bar_hidden;
633        }
634
635        // 2. Restore plugin global state
636        if !workspace.plugin_global_state.is_empty() {
637            tracing::debug!(
638                "Restoring plugin global state for {} plugins",
639                workspace.plugin_global_state.len()
640            );
641            self.plugin_global_state = workspace.plugin_global_state.clone();
642        }
643
644        // 3. Restore search options
645        self.search_case_sensitive = workspace.search_options.case_sensitive;
646        self.search_whole_word = workspace.search_options.whole_word;
647        self.search_use_regex = workspace.search_options.use_regex;
648        self.search_confirm_each = workspace.search_options.confirm_each;
649
650        // 3. Restore histories (merge with any existing)
651        tracing::debug!(
652            "Restoring histories: {} search, {} replace, {} goto_line",
653            workspace.histories.search.len(),
654            workspace.histories.replace.len(),
655            workspace.histories.goto_line.len()
656        );
657        for item in &workspace.histories.search {
658            self.get_or_create_prompt_history("search")
659                .push(item.clone());
660        }
661        for item in &workspace.histories.replace {
662            self.get_or_create_prompt_history("replace")
663                .push(item.clone());
664        }
665        for item in &workspace.histories.goto_line {
666            self.get_or_create_prompt_history("goto_line")
667                .push(item.clone());
668        }
669
670        // 4. Restore file explorer state
671        self.file_explorer_visible = workspace.file_explorer.visible;
672        self.file_explorer_width_percent = workspace.file_explorer.width_percent;
673
674        // Store pending show_hidden and show_gitignored settings (fixes #569)
675        // These will be applied when the file explorer is initialized (async)
676        if workspace.file_explorer.show_hidden {
677            self.pending_file_explorer_show_hidden = Some(true);
678        }
679        if workspace.file_explorer.show_gitignored {
680            self.pending_file_explorer_show_gitignored = Some(true);
681        }
682
683        // Initialize file explorer if it was visible in the workspace
684        // Note: We keep key_context as Normal so the editor has focus, not the explorer
685        if self.file_explorer_visible && self.file_explorer.is_none() {
686            self.init_file_explorer();
687        }
688
689        // 5. Open files from the workspace and build buffer mappings
690        // Collect all unique file paths from split_states (which tracks all open files per split)
691        let file_paths = collect_file_paths_from_states(&workspace.split_states);
692        tracing::debug!(
693            "Workspace has {} files to restore: {:?}",
694            file_paths.len(),
695            file_paths
696        );
697        let mut path_to_buffer: HashMap<PathBuf, BufferId> = HashMap::new();
698
699        for rel_path in file_paths {
700            let abs_path = self.working_dir.join(&rel_path);
701            tracing::trace!(
702                "Checking file: {:?} (exists: {})",
703                abs_path,
704                abs_path.exists()
705            );
706            if abs_path.exists() {
707                // Open the file (this will reuse existing buffer if already open)
708                match self.open_file_internal(&abs_path) {
709                    Ok(buffer_id) => {
710                        tracing::debug!("Opened file {:?} as buffer {:?}", rel_path, buffer_id);
711                        path_to_buffer.insert(rel_path, buffer_id);
712                    }
713                    Err(e) => {
714                        tracing::warn!("Failed to open file {:?}: {}", abs_path, e);
715                    }
716                }
717            } else {
718                tracing::debug!("Skipping non-existent file: {:?}", abs_path);
719            }
720        }
721
722        tracing::debug!("Opened {} files from workspace", path_to_buffer.len());
723
724        // 5b. Restore external files (files outside the working directory)
725        // These are stored as absolute paths
726        if !workspace.external_files.is_empty() {
727            tracing::debug!(
728                "Restoring {} external files: {:?}",
729                workspace.external_files.len(),
730                workspace.external_files
731            );
732            for abs_path in &workspace.external_files {
733                if abs_path.exists() {
734                    match self.open_file_internal(abs_path) {
735                        Ok(buffer_id) => {
736                            // Add to path_to_buffer so open_tabs with absolute paths resolve
737                            path_to_buffer.insert(abs_path.clone(), buffer_id);
738                            tracing::debug!(
739                                "Restored external file {:?} as buffer {:?}",
740                                abs_path,
741                                buffer_id
742                            );
743                        }
744                        Err(e) => {
745                            tracing::warn!("Failed to restore external file {:?}: {}", abs_path, e);
746                        }
747                    }
748                } else {
749                    tracing::debug!("Skipping non-existent external file: {:?}", abs_path);
750                }
751            }
752        }
753
754        // 5b2. Apply hot exit recovery: restore unsaved changes to file-backed buffers
755        if self.config.editor.hot_exit {
756            let entries = self.recovery_service.list_recoverable().unwrap_or_default();
757            if !entries.is_empty() {
758                for &buffer_id in path_to_buffer.values() {
759                    let file_path = self
760                        .buffers
761                        .get(&buffer_id)
762                        .and_then(|s| s.buffer.file_path().map(|p| p.to_path_buf()));
763                    let file_path = match file_path {
764                        Some(p) => p,
765                        None => continue,
766                    };
767
768                    // Look for a recovery entry matching this file
769                    let recovery_id = self.recovery_service.get_buffer_id(Some(&file_path));
770                    let entry = entries.iter().find(|e| e.id == recovery_id);
771                    if let Some(entry) = entry {
772                        match self.recovery_service.load_recovery(entry) {
773                            Ok(crate::services::recovery::RecoveryResult::Recovered {
774                                content,
775                                ..
776                            }) => {
777                                // Small file: replace buffer content with full recovered version
778                                if let Some(state) = self.buffers.get_mut(&buffer_id) {
779                                    let current_len = state.buffer.total_bytes();
780                                    let text = String::from_utf8_lossy(&content).into_owned();
781                                    let current =
782                                        state.buffer.get_text_range_mut(0, current_len).ok();
783                                    let current_text = current
784                                        .as_ref()
785                                        .map(|b| String::from_utf8_lossy(b).into_owned());
786                                    if current_text.as_deref() != Some(&text) {
787                                        state.buffer.delete(0..current_len);
788                                        state.buffer.insert(0, &text);
789                                        state.buffer.set_modified(true);
790                                        state.buffer.set_recovery_pending(false);
791                                        tracing::info!(
792                                            "Restored unsaved changes for {:?} from hot exit recovery",
793                                            file_path
794                                        );
795                                    }
796                                }
797                                // Invalidate saved position so undo can't
798                                // incorrectly clear the modified flag
799                                if let Some(log) = self.event_logs.get_mut(&buffer_id) {
800                                    log.clear_saved_position();
801                                }
802                            }
803                            Ok(crate::services::recovery::RecoveryResult::RecoveredChunks {
804                                chunks,
805                                ..
806                            }) => {
807                                // Large file: apply diff chunks on top of on-disk content
808                                if let Some(state) = self.buffers.get_mut(&buffer_id) {
809                                    for chunk in chunks.into_iter().rev() {
810                                        let text =
811                                            String::from_utf8_lossy(&chunk.content).into_owned();
812                                        if chunk.original_len > 0 {
813                                            state.buffer.delete(
814                                                chunk.offset..chunk.offset + chunk.original_len,
815                                            );
816                                        }
817                                        state.buffer.insert(chunk.offset, &text);
818                                    }
819                                    state.buffer.set_modified(true);
820                                    state.buffer.set_recovery_pending(false);
821                                    tracing::info!(
822                                        "Restored unsaved changes (chunked) for {:?} from hot exit recovery",
823                                        file_path
824                                    );
825                                }
826                                // Invalidate saved position so undo can't
827                                // incorrectly clear the modified flag
828                                if let Some(log) = self.event_logs.get_mut(&buffer_id) {
829                                    log.clear_saved_position();
830                                }
831                            }
832                            Ok(
833                                crate::services::recovery::RecoveryResult::OriginalFileModified {
834                                    original_path,
835                                    ..
836                                },
837                            ) => {
838                                let name = original_path
839                                    .file_name()
840                                    .unwrap_or_default()
841                                    .to_string_lossy();
842                                tracing::warn!(
843                                    "{} changed on disk; unsaved changes not restored",
844                                    name
845                                );
846                                self.set_status_message(format!(
847                                    "{} changed on disk; unsaved changes not restored",
848                                    name
849                                ));
850                            }
851                            Ok(_) => {} // Corrupted, NotFound - skip
852                            Err(e) => {
853                                tracing::debug!(
854                                    "Failed to load hot exit recovery for {:?}: {}",
855                                    file_path,
856                                    e
857                                );
858                            }
859                        }
860                    }
861                }
862            }
863        }
864
865        // 5c. Restore unnamed buffers from recovery files
866        let mut unnamed_buffer_map: HashMap<String, BufferId> = HashMap::new();
867        if self.config.editor.hot_exit && !workspace.unnamed_buffers.is_empty() {
868            tracing::debug!(
869                "Restoring {} unnamed buffers from recovery",
870                workspace.unnamed_buffers.len()
871            );
872            for unnamed_ref in &workspace.unnamed_buffers {
873                // Try to load content from recovery files
874                let entries = match self.recovery_service.list_recoverable() {
875                    Ok(e) => e,
876                    Err(e) => {
877                        tracing::warn!("Failed to list recovery entries: {}", e);
878                        continue;
879                    }
880                };
881
882                let entry = entries.iter().find(|e| e.id == unnamed_ref.recovery_id);
883                if let Some(entry) = entry {
884                    match self.recovery_service.load_recovery(entry) {
885                        Ok(crate::services::recovery::RecoveryResult::Recovered {
886                            content,
887                            ..
888                        }) => {
889                            let text = String::from_utf8_lossy(&content).into_owned();
890                            let buffer_id = self.new_buffer();
891                            {
892                                let state = self.active_state_mut();
893                                state.buffer.insert(0, &text);
894                                // Mark as modified so it shows the dot indicator
895                                state.buffer.set_modified(true);
896                                state.buffer.set_recovery_pending(false);
897                            }
898                            // Invalidate saved position so undo can't
899                            // incorrectly clear the modified flag
900                            self.active_event_log_mut().clear_saved_position();
901
902                            // Store recovery ID in metadata for future saves
903                            if let Some(meta) = self.buffer_metadata.get_mut(&buffer_id) {
904                                meta.recovery_id = Some(unnamed_ref.recovery_id.clone());
905                                meta.display_name = unnamed_ref.display_name.clone();
906                            }
907
908                            unnamed_buffer_map.insert(unnamed_ref.recovery_id.clone(), buffer_id);
909                            tracing::info!(
910                                "Restored unnamed buffer '{}' (recovery_id={})",
911                                unnamed_ref.display_name,
912                                unnamed_ref.recovery_id
913                            );
914                        }
915                        Ok(other) => {
916                            tracing::warn!(
917                                "Unexpected recovery result for unnamed buffer {}: {:?}",
918                                unnamed_ref.recovery_id,
919                                std::mem::discriminant(&other)
920                            );
921                        }
922                        Err(e) => {
923                            tracing::warn!(
924                                "Failed to load recovery for unnamed buffer {}: {}",
925                                unnamed_ref.recovery_id,
926                                e
927                            );
928                        }
929                    }
930                } else {
931                    tracing::debug!(
932                        "Recovery file not found for unnamed buffer {}",
933                        unnamed_ref.recovery_id
934                    );
935                }
936            }
937        }
938
939        // Restore terminals and build index -> buffer map
940        let mut terminal_buffer_map: HashMap<usize, BufferId> = HashMap::new();
941        if !workspace.terminals.is_empty() {
942            if let Some(ref bridge) = self.async_bridge {
943                self.terminal_manager.set_async_bridge(bridge.clone());
944            }
945            for terminal in &workspace.terminals {
946                if let Some(buffer_id) = self.restore_terminal_from_workspace(terminal) {
947                    terminal_buffer_map.insert(terminal.terminal_index, buffer_id);
948                }
949            }
950        }
951
952        // 6. Rebuild split layout from the saved tree
953        // Map old split IDs to new ones as we create splits
954        let mut split_id_map: HashMap<usize, SplitId> = HashMap::new();
955        self.restore_split_node(
956            &workspace.split_layout,
957            &path_to_buffer,
958            &terminal_buffer_map,
959            &unnamed_buffer_map,
960            &workspace.split_states,
961            &mut split_id_map,
962            true, // is_first_leaf - the first leaf reuses the existing split
963        );
964
965        // Set the active split based on the saved active_split_id
966        // NOTE: active_buffer is now derived from split_manager, which was already
967        // correctly set up by restore_split_view_state() via set_split_buffer()
968        if let Some(&new_active_split) = split_id_map.get(&workspace.active_split_id) {
969            self.split_manager
970                .set_active_split(LeafId(new_active_split));
971        }
972
973        // 7. Restore bookmarks
974        for (key, bookmark) in &workspace.bookmarks {
975            if let Some(&buffer_id) = path_to_buffer.get(&bookmark.file_path) {
976                // Verify position is valid
977                if let Some(buffer) = self.buffers.get(&buffer_id) {
978                    let pos = bookmark.position.min(buffer.buffer.len());
979                    self.bookmarks.insert(
980                        *key,
981                        Bookmark {
982                            buffer_id,
983                            position: pos,
984                        },
985                    );
986                }
987            }
988        }
989
990        // Clean up orphaned buffers: the initial empty buffer created at startup
991        // may no longer be referenced by any split after workspace restore.
992        let referenced: HashSet<BufferId> = self
993            .split_view_states
994            .values()
995            .flat_map(|vs| vs.open_buffers.iter().copied())
996            .collect();
997        let orphans: Vec<BufferId> =
998            self.buffers
999                .keys()
1000                .copied()
1001                .filter(|id| {
1002                    !referenced.contains(id)
1003                        && self.buffers.get(id).is_some_and(|s| {
1004                            s.buffer.file_path().is_none() && !s.buffer.is_modified()
1005                        })
1006                })
1007                .collect();
1008        for id in orphans {
1009            tracing::debug!("Removing orphaned empty unnamed buffer {:?}", id);
1010            self.buffers.remove(&id);
1011            self.event_logs.remove(&id);
1012            self.buffer_metadata.remove(&id);
1013        }
1014
1015        // Count restored buffers (excluding hidden/virtual)
1016        let restored_count = self
1017            .buffers
1018            .keys()
1019            .filter(|id| {
1020                self.buffer_metadata
1021                    .get(id)
1022                    .is_some_and(|m| !m.hidden_from_tabs && !m.is_virtual())
1023            })
1024            .count();
1025        if restored_count > 0 {
1026            let session_label = self
1027                .session_name
1028                .as_ref()
1029                .map(|n| format!("session '{}'", n));
1030            let msg = if let Some(label) = session_label {
1031                format!("Restored {} ({} buffer(s))", label, restored_count)
1032            } else {
1033                format!(
1034                    "Restored {} buffer(s) from previous session",
1035                    restored_count
1036                )
1037            };
1038            self.set_status_message(msg);
1039        }
1040
1041        tracing::debug!(
1042            "Workspace restore complete: {} splits, {} buffers",
1043            self.split_view_states.len(),
1044            self.buffers.len()
1045        );
1046
1047        // Fire buffer_activated for the active buffer so plugins can
1048        // re-enable compose mode (the plugin's composeBuffers set is empty
1049        // after restart). Only fires for the active buffer — other buffers
1050        // will get buffer_activated when the user switches to them.
1051        #[cfg(feature = "plugins")]
1052        {
1053            let buffer_id = self.active_buffer();
1054            self.update_plugin_state_snapshot();
1055            tracing::debug!(
1056                "Firing buffer_activated for active buffer {:?} after workspace restore",
1057                buffer_id
1058            );
1059            self.plugin_manager.run_hook(
1060                "buffer_activated",
1061                crate::services::plugins::hooks::HookArgs::BufferActivated { buffer_id },
1062            );
1063        }
1064
1065        Ok(())
1066    }
1067
1068    /// Restore a terminal from serialized workspace metadata.
1069    ///
1070    /// Uses the incremental streaming architecture for fast restore:
1071    /// 1. Load backing file directly as read-only buffer (lazy load)
1072    /// 2. Skip log replay entirely - user sees last workspace state immediately
1073    /// 3. Spawn new PTY for live terminal when user re-enters terminal mode
1074    ///
1075    /// Performance: O(1) for restore vs O(total_history) with log replay
1076    fn restore_terminal_from_workspace(
1077        &mut self,
1078        terminal: &SerializedTerminalWorkspace,
1079    ) -> Option<BufferId> {
1080        // Resolve paths (accept absolute; otherwise treat as relative to terminals dir)
1081        let terminals_root = self.dir_context.terminal_dir_for(&self.working_dir);
1082        let log_path = if terminal.log_path.is_absolute() {
1083            terminal.log_path.clone()
1084        } else {
1085            terminals_root.join(&terminal.log_path)
1086        };
1087        let backing_path = if terminal.backing_path.is_absolute() {
1088            terminal.backing_path.clone()
1089        } else {
1090            terminals_root.join(&terminal.backing_path)
1091        };
1092
1093        // Best-effort directory creation for terminal backing files
1094        #[allow(clippy::let_underscore_must_use)]
1095        let _ = self.filesystem.create_dir_all(
1096            log_path
1097                .parent()
1098                .or_else(|| backing_path.parent())
1099                .unwrap_or(&terminals_root),
1100        );
1101
1102        // Record paths using the predicted ID so buffer creation can reuse them
1103        let predicted_id = self.terminal_manager.next_terminal_id();
1104        self.terminal_log_files
1105            .insert(predicted_id, log_path.clone());
1106        self.terminal_backing_files
1107            .insert(predicted_id, backing_path.clone());
1108
1109        // Spawn the terminal with backing file for incremental scrollback
1110        let terminal_id = match self.terminal_manager.spawn(
1111            terminal.cols,
1112            terminal.rows,
1113            terminal.cwd.clone(),
1114            Some(log_path.clone()),
1115            Some(backing_path.clone()),
1116        ) {
1117            Ok(id) => id,
1118            Err(e) => {
1119                tracing::warn!(
1120                    "Failed to restore terminal {}: {}",
1121                    terminal.terminal_index,
1122                    e
1123                );
1124                return None;
1125            }
1126        };
1127
1128        // Ensure maps keyed by actual ID
1129        if terminal_id != predicted_id {
1130            self.terminal_log_files
1131                .insert(terminal_id, log_path.clone());
1132            self.terminal_backing_files
1133                .insert(terminal_id, backing_path.clone());
1134            self.terminal_log_files.remove(&predicted_id);
1135            self.terminal_backing_files.remove(&predicted_id);
1136        }
1137
1138        // Create buffer for this terminal
1139        let buffer_id = self.create_terminal_buffer_detached(terminal_id);
1140
1141        // Load backing file directly as read-only buffer (skip log replay)
1142        // The backing file already contains complete terminal state from last workspace
1143        self.load_terminal_backing_file_as_buffer(buffer_id, &backing_path);
1144
1145        Some(buffer_id)
1146    }
1147
1148    /// Load a terminal backing file directly as a read-only buffer.
1149    ///
1150    /// This is used for fast workspace restore - we load the pre-rendered backing
1151    /// file instead of replaying the raw log through the VTE parser.
1152    fn load_terminal_backing_file_as_buffer(&mut self, buffer_id: BufferId, backing_path: &Path) {
1153        // Check if backing file exists; if not, terminal starts empty
1154        if !backing_path.exists() {
1155            return;
1156        }
1157
1158        let large_file_threshold = self.config.editor.large_file_threshold_bytes as usize;
1159        if let Ok(new_state) = EditorState::from_file_with_languages(
1160            backing_path,
1161            self.terminal_width,
1162            self.terminal_height,
1163            large_file_threshold,
1164            &self.grammar_registry,
1165            &self.config.languages,
1166            std::sync::Arc::clone(&self.filesystem),
1167        ) {
1168            if let Some(state) = self.buffers.get_mut(&buffer_id) {
1169                *state = new_state;
1170                // Move cursor to end of buffer
1171                let total = state.buffer.total_bytes();
1172                // Update cursor position in all splits that show this buffer
1173                for vs in self.split_view_states.values_mut() {
1174                    if vs.open_buffers.contains(&buffer_id) {
1175                        vs.cursors.primary_mut().position = total;
1176                    }
1177                }
1178                // Terminal buffers should never be considered "modified"
1179                state.buffer.set_modified(false);
1180                // Start in scrollback mode (editing disabled)
1181                state.editing_disabled = true;
1182                state.margins.configure_for_line_numbers(false);
1183            }
1184        }
1185    }
1186
1187    /// Internal helper to open a file and return its buffer ID
1188    fn open_file_internal(&mut self, path: &Path) -> Result<BufferId, WorkspaceError> {
1189        // Check if file is already open
1190        for (buffer_id, metadata) in &self.buffer_metadata {
1191            if let Some(file_path) = metadata.file_path() {
1192                if file_path == path {
1193                    return Ok(*buffer_id);
1194                }
1195            }
1196        }
1197
1198        // File not open, open it using the Editor's open_file method
1199        self.open_file(path).map_err(WorkspaceError::Io)
1200    }
1201
1202    /// Recursively restore the split layout from a serialized tree
1203    #[allow(clippy::too_many_arguments)]
1204    fn restore_split_node(
1205        &mut self,
1206        node: &SerializedSplitNode,
1207        path_to_buffer: &HashMap<PathBuf, BufferId>,
1208        terminal_buffers: &HashMap<usize, BufferId>,
1209        unnamed_buffers: &HashMap<String, BufferId>,
1210        split_states: &HashMap<usize, SerializedSplitViewState>,
1211        split_id_map: &mut HashMap<usize, SplitId>,
1212        is_first_leaf: bool,
1213    ) {
1214        match node {
1215            SerializedSplitNode::Leaf {
1216                file_path,
1217                split_id,
1218                label,
1219                unnamed_recovery_id,
1220            } => {
1221                // Get the buffer for this leaf: file path, unnamed recovery ID, or default
1222                let buffer_id = file_path
1223                    .as_ref()
1224                    .and_then(|p| path_to_buffer.get(p).copied())
1225                    .or_else(|| {
1226                        unnamed_recovery_id
1227                            .as_ref()
1228                            .and_then(|id| unnamed_buffers.get(id).copied())
1229                    })
1230                    .unwrap_or(self.active_buffer());
1231
1232                let current_leaf_id = if is_first_leaf {
1233                    // First leaf reuses the existing split
1234                    let leaf_id = self.split_manager.active_split();
1235                    self.split_manager.set_split_buffer(leaf_id, buffer_id);
1236                    leaf_id
1237                } else {
1238                    // Non-first leaves use the active split (created by split_active)
1239                    self.split_manager.active_split()
1240                };
1241
1242                // Map old split ID to new one
1243                split_id_map.insert(*split_id, current_leaf_id.into());
1244
1245                // Restore label if present
1246                if let Some(label) = label {
1247                    self.split_manager.set_label(current_leaf_id, label.clone());
1248                }
1249
1250                // Restore the view state for this split
1251                self.restore_split_view_state(
1252                    current_leaf_id,
1253                    *split_id,
1254                    split_states,
1255                    path_to_buffer,
1256                    terminal_buffers,
1257                    unnamed_buffers,
1258                );
1259            }
1260            SerializedSplitNode::Terminal {
1261                terminal_index,
1262                split_id,
1263                label,
1264            } => {
1265                let buffer_id = terminal_buffers
1266                    .get(terminal_index)
1267                    .copied()
1268                    .unwrap_or(self.active_buffer());
1269
1270                let current_leaf_id = if is_first_leaf {
1271                    let leaf_id = self.split_manager.active_split();
1272                    self.split_manager.set_split_buffer(leaf_id, buffer_id);
1273                    leaf_id
1274                } else {
1275                    self.split_manager.active_split()
1276                };
1277
1278                split_id_map.insert(*split_id, current_leaf_id.into());
1279
1280                // Restore label if present
1281                if let Some(label) = label {
1282                    self.split_manager.set_label(current_leaf_id, label.clone());
1283                }
1284
1285                self.split_manager
1286                    .set_split_buffer(current_leaf_id, buffer_id);
1287
1288                self.restore_split_view_state(
1289                    current_leaf_id,
1290                    *split_id,
1291                    split_states,
1292                    path_to_buffer,
1293                    terminal_buffers,
1294                    unnamed_buffers,
1295                );
1296            }
1297            SerializedSplitNode::Split {
1298                direction,
1299                first,
1300                second,
1301                ratio,
1302                split_id,
1303            } => {
1304                // First, restore the first child (it uses the current active split)
1305                self.restore_split_node(
1306                    first,
1307                    path_to_buffer,
1308                    terminal_buffers,
1309                    unnamed_buffers,
1310                    split_states,
1311                    split_id_map,
1312                    is_first_leaf,
1313                );
1314
1315                // Get the buffer for the second child's first leaf
1316                let second_buffer_id = get_first_leaf_buffer(
1317                    second,
1318                    path_to_buffer,
1319                    terminal_buffers,
1320                    unnamed_buffers,
1321                )
1322                .unwrap_or(self.active_buffer());
1323
1324                // Convert direction
1325                let split_direction = match direction {
1326                    SerializedSplitDirection::Horizontal => SplitDirection::Horizontal,
1327                    SerializedSplitDirection::Vertical => SplitDirection::Vertical,
1328                };
1329
1330                // Create the split for the second child
1331                match self
1332                    .split_manager
1333                    .split_active(split_direction, second_buffer_id, *ratio)
1334                {
1335                    Ok(new_leaf_id) => {
1336                        // Create view state for the new split
1337                        let mut view_state = SplitViewState::with_buffer(
1338                            self.terminal_width,
1339                            self.terminal_height,
1340                            second_buffer_id,
1341                        );
1342                        view_state.apply_config_defaults(
1343                            self.config.editor.line_numbers,
1344                            self.config.editor.highlight_current_line,
1345                            self.resolve_line_wrap_for_buffer(second_buffer_id),
1346                            self.config.editor.wrap_indent,
1347                            self.resolve_wrap_column_for_buffer(second_buffer_id),
1348                            self.config.editor.rulers.clone(),
1349                        );
1350                        self.split_view_states.insert(new_leaf_id, view_state);
1351
1352                        // Map the container split ID (though we mainly care about leaves)
1353                        split_id_map.insert(*split_id, new_leaf_id.into());
1354
1355                        // Recursively restore the second child (it's now in the new split)
1356                        self.restore_split_node(
1357                            second,
1358                            path_to_buffer,
1359                            terminal_buffers,
1360                            unnamed_buffers,
1361                            split_states,
1362                            split_id_map,
1363                            false,
1364                        );
1365                    }
1366                    Err(e) => {
1367                        tracing::error!("Failed to create split during workspace restore: {}", e);
1368                    }
1369                }
1370            }
1371        }
1372    }
1373
1374    /// Restore view state for a specific split
1375    fn restore_split_view_state(
1376        &mut self,
1377        current_split_id: LeafId,
1378        saved_split_id: usize,
1379        split_states: &HashMap<usize, SerializedSplitViewState>,
1380        path_to_buffer: &HashMap<PathBuf, BufferId>,
1381        terminal_buffers: &HashMap<usize, BufferId>,
1382        unnamed_buffers: &HashMap<String, BufferId>,
1383    ) {
1384        // Try to find the saved state for this split
1385        let Some(split_state) = split_states.get(&saved_split_id) else {
1386            return;
1387        };
1388
1389        let Some(view_state) = self.split_view_states.get_mut(&current_split_id) else {
1390            return;
1391        };
1392
1393        let mut active_buffer_id: Option<BufferId> = None;
1394
1395        if !split_state.open_tabs.is_empty() {
1396            // Clear pre-existing open_buffers (e.g. the initial empty buffer
1397            // created at startup) so only the saved tabs appear.
1398            view_state.open_buffers.clear();
1399
1400            for tab in &split_state.open_tabs {
1401                match tab {
1402                    SerializedTabRef::File(rel_path) => {
1403                        if let Some(&buffer_id) = path_to_buffer.get(rel_path) {
1404                            if !view_state.open_buffers.contains(&buffer_id) {
1405                                view_state.open_buffers.push(buffer_id);
1406                            }
1407                            // Ensure keyed state exists for this buffer
1408                            view_state.ensure_buffer_state(buffer_id);
1409                            if terminal_buffers.values().any(|&tid| tid == buffer_id) {
1410                                view_state
1411                                    .buffer_state_mut(buffer_id)
1412                                    .unwrap()
1413                                    .viewport
1414                                    .line_wrap_enabled = false;
1415                            }
1416                        }
1417                    }
1418                    SerializedTabRef::Terminal(index) => {
1419                        if let Some(&buffer_id) = terminal_buffers.get(index) {
1420                            if !view_state.open_buffers.contains(&buffer_id) {
1421                                view_state.open_buffers.push(buffer_id);
1422                            }
1423                            view_state
1424                                .ensure_buffer_state(buffer_id)
1425                                .viewport
1426                                .line_wrap_enabled = false;
1427                        }
1428                    }
1429                    SerializedTabRef::Unnamed(recovery_id) => {
1430                        if let Some(&buffer_id) = unnamed_buffers.get(recovery_id) {
1431                            if !view_state.open_buffers.contains(&buffer_id) {
1432                                view_state.open_buffers.push(buffer_id);
1433                            }
1434                            view_state.ensure_buffer_state(buffer_id);
1435                        }
1436                    }
1437                }
1438            }
1439
1440            // If all saved tabs referenced deleted/missing files, open_buffers
1441            // is now empty. Re-add the buffer that the split manager assigned to
1442            // this split so the orphan cleanup won't remove a buffer the split
1443            // manager still points to (#1278).
1444            if view_state.open_buffers.is_empty() {
1445                if let Some(buf) = self.split_manager.buffer_for_split(current_split_id) {
1446                    view_state.open_buffers.push(buf);
1447                    view_state.ensure_buffer_state(buf);
1448                }
1449            }
1450
1451            if let Some(active_idx) = split_state.active_tab_index {
1452                if let Some(tab) = split_state.open_tabs.get(active_idx) {
1453                    active_buffer_id = match tab {
1454                        SerializedTabRef::File(rel) => path_to_buffer.get(rel).copied(),
1455                        SerializedTabRef::Terminal(index) => terminal_buffers.get(index).copied(),
1456                        SerializedTabRef::Unnamed(id) => unnamed_buffers.get(id).copied(),
1457                    };
1458                }
1459            }
1460        } else {
1461            // Backward compatibility path using open_files/active_file_index
1462            for rel_path in &split_state.open_files {
1463                if let Some(&buffer_id) = path_to_buffer.get(rel_path) {
1464                    if !view_state.open_buffers.contains(&buffer_id) {
1465                        view_state.open_buffers.push(buffer_id);
1466                    }
1467                    view_state.ensure_buffer_state(buffer_id);
1468                }
1469            }
1470
1471            let active_file_path = split_state.open_files.get(split_state.active_file_index);
1472            active_buffer_id =
1473                active_file_path.and_then(|rel_path| path_to_buffer.get(rel_path).copied());
1474        }
1475
1476        // Restore cursor, scroll, view_mode, and compose_width for ALL buffers in file_states
1477        for (rel_path, file_state) in &split_state.file_states {
1478            // Look up buffer by path, or by unnamed recovery ID
1479            let rel_str = rel_path.to_string_lossy();
1480            let buffer_id = if let Some(recovery_id) = rel_str.strip_prefix("__unnamed__") {
1481                match unnamed_buffers.get(recovery_id).copied() {
1482                    Some(id) => id,
1483                    None => continue,
1484                }
1485            } else {
1486                match path_to_buffer.get(rel_path).copied() {
1487                    Some(id) => id,
1488                    None => continue,
1489                }
1490            };
1491            let max_pos = self
1492                .buffers
1493                .get(&buffer_id)
1494                .map(|b| b.buffer.len())
1495                .unwrap_or(0);
1496
1497            // Ensure keyed state exists for this buffer
1498            let buf_state = view_state.ensure_buffer_state(buffer_id);
1499
1500            let cursor_pos = file_state.cursor.position.min(max_pos);
1501            buf_state.cursors.primary_mut().position = cursor_pos;
1502            buf_state.cursors.primary_mut().anchor =
1503                file_state.cursor.anchor.map(|a| a.min(max_pos));
1504            buf_state.cursors.primary_mut().sticky_column = file_state.cursor.sticky_column;
1505
1506            buf_state.viewport.top_byte = file_state.scroll.top_byte.min(max_pos);
1507            buf_state.viewport.top_view_line_offset = file_state.scroll.top_view_line_offset;
1508            buf_state.viewport.left_column = file_state.scroll.left_column;
1509            buf_state.viewport.set_skip_resize_sync();
1510
1511            // Restore per-buffer view mode and compose width
1512            buf_state.view_mode = match file_state.view_mode {
1513                SerializedViewMode::Source => ViewMode::Source,
1514                SerializedViewMode::PageView => ViewMode::PageView,
1515            };
1516            buf_state.compose_width = file_state.compose_width;
1517            buf_state.plugin_state = file_state.plugin_state.clone();
1518            if let Some(state) = self.buffers.get_mut(&buffer_id) {
1519                buf_state.folds.clear(&mut state.marker_list);
1520                for fold in &file_state.folds {
1521                    let start_line = fold.header_line.saturating_add(1);
1522                    let end_line = fold.end_line;
1523                    if start_line > end_line {
1524                        continue;
1525                    }
1526                    let Some(start_byte) = state.buffer.line_start_offset(start_line) else {
1527                        continue;
1528                    };
1529                    let end_byte = state
1530                        .buffer
1531                        .line_start_offset(end_line.saturating_add(1))
1532                        .unwrap_or_else(|| state.buffer.len());
1533                    buf_state.folds.add(
1534                        &mut state.marker_list,
1535                        start_byte,
1536                        end_byte,
1537                        fold.placeholder.clone(),
1538                    );
1539                }
1540            }
1541
1542            tracing::trace!(
1543                "Restored keyed state for {:?}: cursor={}, top_byte={}, view_mode={:?}",
1544                rel_path,
1545                cursor_pos,
1546                buf_state.viewport.top_byte,
1547                buf_state.view_mode,
1548            );
1549        }
1550
1551        // For buffers without saved file_state (e.g., terminals), apply split-level
1552        // view_mode/compose_width as fallback (backward compatibility)
1553        let restored_view_mode = match split_state.view_mode {
1554            SerializedViewMode::Source => ViewMode::Source,
1555            SerializedViewMode::PageView => ViewMode::PageView,
1556        };
1557
1558        if let Some(active_id) = active_buffer_id {
1559            // Switch the split to the active buffer
1560            view_state.switch_buffer(active_id);
1561
1562            // If no per-buffer file_state was saved, apply split-level settings
1563            let active_has_file_state = split_state
1564                .file_states
1565                .keys()
1566                .any(|rel_path| path_to_buffer.get(rel_path).copied() == Some(active_id));
1567            if !active_has_file_state {
1568                view_state.active_state_mut().view_mode = restored_view_mode.clone();
1569                view_state.active_state_mut().compose_width = split_state.compose_width;
1570            }
1571
1572            // Cursors now live in SplitViewState, no need to sync to EditorState
1573
1574            // Set this buffer as active in the split (fires buffer_activated hook)
1575            self.split_manager
1576                .set_split_buffer(current_split_id, active_id);
1577        }
1578        view_state.tab_scroll_offset = split_state.tab_scroll_offset;
1579    }
1580}
1581
1582/// Helper: Get the buffer ID from the first leaf node in a split tree
1583fn get_first_leaf_buffer(
1584    node: &SerializedSplitNode,
1585    path_to_buffer: &HashMap<PathBuf, BufferId>,
1586    terminal_buffers: &HashMap<usize, BufferId>,
1587    unnamed_buffers: &HashMap<String, BufferId>,
1588) -> Option<BufferId> {
1589    match node {
1590        SerializedSplitNode::Leaf {
1591            file_path,
1592            unnamed_recovery_id,
1593            ..
1594        } => file_path
1595            .as_ref()
1596            .and_then(|p| path_to_buffer.get(p).copied())
1597            .or_else(|| {
1598                unnamed_recovery_id
1599                    .as_ref()
1600                    .and_then(|id| unnamed_buffers.get(id).copied())
1601            }),
1602        SerializedSplitNode::Terminal { terminal_index, .. } => {
1603            terminal_buffers.get(terminal_index).copied()
1604        }
1605        SerializedSplitNode::Split { first, .. } => {
1606            get_first_leaf_buffer(first, path_to_buffer, terminal_buffers, unnamed_buffers)
1607        }
1608    }
1609}
1610
1611// ============================================================================
1612// Serialization helpers
1613// ============================================================================
1614
1615fn serialize_split_node(
1616    node: &SplitNode,
1617    buffer_metadata: &HashMap<BufferId, super::types::BufferMetadata>,
1618    working_dir: &Path,
1619    terminal_buffers: &HashMap<BufferId, TerminalId>,
1620    terminal_indices: &HashMap<TerminalId, usize>,
1621    split_labels: &HashMap<SplitId, String>,
1622) -> SerializedSplitNode {
1623    match node {
1624        SplitNode::Leaf {
1625            buffer_id,
1626            split_id,
1627        } => {
1628            let raw_split_id: SplitId = (*split_id).into();
1629            let label = split_labels.get(&raw_split_id).cloned();
1630
1631            if let Some(terminal_id) = terminal_buffers.get(buffer_id) {
1632                if let Some(index) = terminal_indices.get(terminal_id) {
1633                    return SerializedSplitNode::Terminal {
1634                        terminal_index: *index,
1635                        split_id: raw_split_id.0,
1636                        label,
1637                    };
1638                }
1639            }
1640
1641            let meta = buffer_metadata.get(buffer_id);
1642            let file_path = meta.and_then(|m| m.file_path()).and_then(|abs_path| {
1643                if abs_path.as_os_str().is_empty() {
1644                    None // unnamed buffer
1645                } else {
1646                    abs_path
1647                        .strip_prefix(working_dir)
1648                        .ok()
1649                        .map(|p| p.to_path_buf())
1650                }
1651            });
1652
1653            // For unnamed buffers, emit their recovery ID so workspace restore
1654            // can load content from recovery files
1655            let unnamed_recovery_id = if file_path.is_none() {
1656                meta.and_then(|m| m.recovery_id.clone())
1657            } else {
1658                None
1659            };
1660
1661            SerializedSplitNode::Leaf {
1662                file_path,
1663                split_id: raw_split_id.0,
1664                label,
1665                unnamed_recovery_id,
1666            }
1667        }
1668        SplitNode::Split {
1669            direction,
1670            first,
1671            second,
1672            ratio,
1673            split_id,
1674        } => {
1675            let raw_split_id: SplitId = (*split_id).into();
1676            SerializedSplitNode::Split {
1677                direction: match direction {
1678                    SplitDirection::Horizontal => SerializedSplitDirection::Horizontal,
1679                    SplitDirection::Vertical => SerializedSplitDirection::Vertical,
1680                },
1681                first: Box::new(serialize_split_node(
1682                    first,
1683                    buffer_metadata,
1684                    working_dir,
1685                    terminal_buffers,
1686                    terminal_indices,
1687                    split_labels,
1688                )),
1689                second: Box::new(serialize_split_node(
1690                    second,
1691                    buffer_metadata,
1692                    working_dir,
1693                    terminal_buffers,
1694                    terminal_indices,
1695                    split_labels,
1696                )),
1697                ratio: *ratio,
1698                split_id: raw_split_id.0,
1699            }
1700        }
1701    }
1702}
1703
1704fn serialize_split_view_state(
1705    view_state: &crate::view::split::SplitViewState,
1706    buffers: &HashMap<BufferId, EditorState>,
1707    buffer_metadata: &HashMap<BufferId, super::types::BufferMetadata>,
1708    working_dir: &Path,
1709    active_buffer: Option<BufferId>,
1710    terminal_buffers: &HashMap<BufferId, TerminalId>,
1711    terminal_indices: &HashMap<TerminalId, usize>,
1712) -> SerializedSplitViewState {
1713    let mut open_tabs = Vec::new();
1714    let mut open_files = Vec::new();
1715    let mut active_tab_index = None;
1716
1717    for buffer_id in &view_state.open_buffers {
1718        let tab_index = open_tabs.len();
1719        if let Some(terminal_id) = terminal_buffers.get(buffer_id) {
1720            if let Some(idx) = terminal_indices.get(terminal_id) {
1721                open_tabs.push(SerializedTabRef::Terminal(*idx));
1722                if Some(*buffer_id) == active_buffer {
1723                    active_tab_index = Some(tab_index);
1724                }
1725                continue;
1726            }
1727        }
1728
1729        if let Some(meta) = buffer_metadata.get(buffer_id) {
1730            if let Some(abs_path) = meta.file_path() {
1731                if abs_path.as_os_str().is_empty() {
1732                    // Unnamed buffer - reference by recovery ID
1733                    if let Some(ref recovery_id) = meta.recovery_id {
1734                        open_tabs.push(SerializedTabRef::Unnamed(recovery_id.clone()));
1735                        if Some(*buffer_id) == active_buffer {
1736                            active_tab_index = Some(tab_index);
1737                        }
1738                    }
1739                } else if let Ok(rel_path) = abs_path.strip_prefix(working_dir) {
1740                    open_tabs.push(SerializedTabRef::File(rel_path.to_path_buf()));
1741                    open_files.push(rel_path.to_path_buf());
1742                    if Some(*buffer_id) == active_buffer {
1743                        active_tab_index = Some(tab_index);
1744                    }
1745                } else {
1746                    // External file (outside working_dir) - store absolute path
1747                    open_tabs.push(SerializedTabRef::File(abs_path.to_path_buf()));
1748                    if Some(*buffer_id) == active_buffer {
1749                        active_tab_index = Some(tab_index);
1750                    }
1751                }
1752            }
1753        }
1754    }
1755
1756    // Derive active_file_index for backward compatibility
1757    let active_file_index = active_tab_index
1758        .and_then(|idx| open_tabs.get(idx))
1759        .and_then(|tab| match tab {
1760            SerializedTabRef::File(path) => {
1761                Some(open_files.iter().position(|p| p == path).unwrap_or(0))
1762            }
1763            _ => None,
1764        })
1765        .unwrap_or(0);
1766
1767    // Serialize file states for ALL buffers in keyed_states (not just the active one)
1768    let mut file_states = HashMap::new();
1769    for (buffer_id, buf_state) in &view_state.keyed_states {
1770        let Some(meta) = buffer_metadata.get(buffer_id) else {
1771            continue;
1772        };
1773        let Some(abs_path) = meta.file_path() else {
1774            continue;
1775        };
1776
1777        // Determine the key for this buffer's state
1778        let state_key = if abs_path.as_os_str().is_empty() {
1779            // Unnamed buffer - use recovery ID as key
1780            if let Some(ref recovery_id) = meta.recovery_id {
1781                PathBuf::from(format!("__unnamed__{}", recovery_id))
1782            } else {
1783                continue;
1784            }
1785        } else if let Ok(rp) = abs_path.strip_prefix(working_dir) {
1786            rp.to_path_buf()
1787        } else {
1788            // External file - use absolute path as key
1789            abs_path.to_path_buf()
1790        };
1791
1792        let primary_cursor = buf_state.cursors.primary();
1793        let folds = buffers
1794            .get(buffer_id)
1795            .map(|state| {
1796                buf_state
1797                    .folds
1798                    .collapsed_line_ranges(&state.buffer, &state.marker_list)
1799                    .into_iter()
1800                    .map(|range| SerializedFoldRange {
1801                        header_line: range.header_line,
1802                        end_line: range.end_line,
1803                        placeholder: range.placeholder,
1804                    })
1805                    .collect::<Vec<_>>()
1806            })
1807            .unwrap_or_default();
1808
1809        file_states.insert(
1810            state_key,
1811            SerializedFileState {
1812                cursor: SerializedCursor {
1813                    position: primary_cursor.position,
1814                    anchor: primary_cursor.anchor,
1815                    sticky_column: primary_cursor.sticky_column,
1816                },
1817                additional_cursors: buf_state
1818                    .cursors
1819                    .iter()
1820                    .skip(1) // Skip primary
1821                    .map(|(_, cursor)| SerializedCursor {
1822                        position: cursor.position,
1823                        anchor: cursor.anchor,
1824                        sticky_column: cursor.sticky_column,
1825                    })
1826                    .collect(),
1827                scroll: SerializedScroll {
1828                    top_byte: buf_state.viewport.top_byte,
1829                    top_view_line_offset: buf_state.viewport.top_view_line_offset,
1830                    left_column: buf_state.viewport.left_column,
1831                },
1832                view_mode: match buf_state.view_mode {
1833                    ViewMode::Source => SerializedViewMode::Source,
1834                    ViewMode::PageView => SerializedViewMode::PageView,
1835                },
1836                compose_width: buf_state.compose_width,
1837                plugin_state: buf_state.plugin_state.clone(),
1838                folds,
1839            },
1840        );
1841    }
1842
1843    // Active buffer's view_mode/compose_width for the split-level fields (backward compat)
1844    let active_view_mode = active_buffer
1845        .and_then(|id| view_state.keyed_states.get(&id))
1846        .map(|bs| match bs.view_mode {
1847            ViewMode::Source => SerializedViewMode::Source,
1848            ViewMode::PageView => SerializedViewMode::PageView,
1849        })
1850        .unwrap_or(SerializedViewMode::Source);
1851    let active_compose_width = active_buffer
1852        .and_then(|id| view_state.keyed_states.get(&id))
1853        .and_then(|bs| bs.compose_width);
1854
1855    SerializedSplitViewState {
1856        open_tabs,
1857        active_tab_index,
1858        open_files,
1859        active_file_index,
1860        file_states,
1861        tab_scroll_offset: view_state.tab_scroll_offset,
1862        view_mode: active_view_mode,
1863        compose_width: active_compose_width,
1864    }
1865}
1866
1867fn serialize_bookmarks(
1868    bookmarks: &HashMap<char, Bookmark>,
1869    buffer_metadata: &HashMap<BufferId, super::types::BufferMetadata>,
1870    working_dir: &Path,
1871) -> HashMap<char, SerializedBookmark> {
1872    bookmarks
1873        .iter()
1874        .filter_map(|(key, bookmark)| {
1875            buffer_metadata
1876                .get(&bookmark.buffer_id)
1877                .and_then(|meta| meta.file_path())
1878                .and_then(|abs_path| {
1879                    abs_path.strip_prefix(working_dir).ok().map(|rel_path| {
1880                        (
1881                            *key,
1882                            SerializedBookmark {
1883                                file_path: rel_path.to_path_buf(),
1884                                position: bookmark.position,
1885                            },
1886                        )
1887                    })
1888                })
1889        })
1890        .collect()
1891}
1892
1893/// Collect all unique file paths from split_states
1894fn collect_file_paths_from_states(
1895    split_states: &HashMap<usize, SerializedSplitViewState>,
1896) -> Vec<PathBuf> {
1897    let mut paths = Vec::new();
1898    for state in split_states.values() {
1899        if !state.open_tabs.is_empty() {
1900            for tab in &state.open_tabs {
1901                if let SerializedTabRef::File(path) = tab {
1902                    if !paths.contains(path) {
1903                        paths.push(path.clone());
1904                    }
1905                }
1906            }
1907        } else {
1908            for path in &state.open_files {
1909                if !paths.contains(path) {
1910                    paths.push(path.clone());
1911                }
1912            }
1913        }
1914    }
1915    paths
1916}
1917
1918/// Get list of expanded directories from a FileTreeView
1919fn get_expanded_dirs(
1920    explorer: &crate::view::file_tree::FileTreeView,
1921    working_dir: &Path,
1922) -> Vec<PathBuf> {
1923    let mut expanded = Vec::new();
1924    let tree = explorer.tree();
1925
1926    // Iterate through all nodes and collect expanded directories
1927    for node in tree.all_nodes() {
1928        if node.is_expanded() && node.is_dir() {
1929            // Get the path and make it relative to working_dir
1930            if let Ok(rel_path) = node.entry.path.strip_prefix(working_dir) {
1931                expanded.push(rel_path.to_path_buf());
1932            }
1933        }
1934    }
1935
1936    expanded
1937}