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