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