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