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