Skip to main content

fresh/app/
workspace.rs

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