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