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