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