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                            self.resources.config.editor.scroll_offset,
1011                        );
1012                        self.buffers
1013                            .split_view_states_mut()
1014                            .expect("active window must have a populated split layout")
1015                            .insert(new_leaf_id, view_state);
1016
1017                        // Map the container split ID (though we mainly care about leaves)
1018                        split_id_map.insert(*split_id, new_leaf_id.into());
1019
1020                        // Recursively restore the second child (it's now in the new split)
1021                        self.restore_split_node(
1022                            second,
1023                            path_to_buffer,
1024                            terminal_buffers,
1025                            unnamed_buffers,
1026                            split_states,
1027                            split_id_map,
1028                            false,
1029                        );
1030                    }
1031                    Err(e) => {
1032                        tracing::error!("Failed to create split during workspace restore: {}", e);
1033                    }
1034                }
1035            }
1036        }
1037    }
1038
1039    /// Restore view state for a specific split
1040    fn restore_split_view_state(
1041        &mut self,
1042        current_split_id: LeafId,
1043        saved_split_id: usize,
1044        split_states: &HashMap<usize, SerializedSplitViewState>,
1045        path_to_buffer: &HashMap<PathBuf, BufferId>,
1046        terminal_buffers: &HashMap<usize, BufferId>,
1047        unnamed_buffers: &HashMap<String, BufferId>,
1048    ) {
1049        // Try to find the saved state for this split
1050        let Some(split_state) = split_states.get(&saved_split_id) else {
1051            return;
1052        };
1053
1054        // Resolve the split-manager-assigned buffer before taking the
1055        // &mut borrow on windows so the borrow stays disjoint from
1056        // any subsequent reads.
1057        let split_buf_for_current = self
1058            .buffers
1059            .split_manager()
1060            .expect("active window must have a populated split layout")
1061            .buffer_for_split(current_split_id);
1062        let active_buffer_id = self
1063            .buffers
1064            .with_all_mut(|__buffers_mut, _mgr, vs_map| {
1065                let Some(view_state) = vs_map.get_mut(&current_split_id) else {
1066                    return None;
1067                };
1068                let mut active_buffer_id: Option<BufferId> = None;
1069                if !split_state.open_tabs.is_empty() {
1070                    // Clear pre-existing open_buffers (e.g. the initial empty buffer
1071                    // created at startup) so only the saved tabs appear.
1072                    view_state.open_buffers.clear();
1073
1074                    for tab in &split_state.open_tabs {
1075                        match tab {
1076                            SerializedTabRef::File(rel_path) => {
1077                                if let Some(&buffer_id) = path_to_buffer.get(rel_path) {
1078                                    if !view_state.has_buffer(buffer_id) {
1079                                        view_state.add_buffer(buffer_id);
1080                                    }
1081                                    // Ensure keyed state exists for this buffer
1082                                    view_state.ensure_buffer_state(buffer_id);
1083                                    if terminal_buffers.values().any(|&tid| tid == buffer_id) {
1084                                        let buf_state =
1085                                            view_state.buffer_state_mut(buffer_id).unwrap();
1086                                        buf_state.viewport.line_wrap_enabled = false;
1087                                        // Match the freshly-spawned terminal path: no
1088                                        // gutter / current-line highlight when this
1089                                        // tab gets entered after workspace restore.
1090                                        buf_state.show_line_numbers = false;
1091                                        buf_state.highlight_current_line = false;
1092                                    }
1093                                }
1094                            }
1095                            SerializedTabRef::Terminal(index) => {
1096                                if let Some(&buffer_id) = terminal_buffers.get(index) {
1097                                    if !view_state.has_buffer(buffer_id) {
1098                                        view_state.add_buffer(buffer_id);
1099                                    }
1100                                    let buf_state = view_state.ensure_buffer_state(buffer_id);
1101                                    buf_state.viewport.line_wrap_enabled = false;
1102                                    // Match the freshly-spawned terminal path: no
1103                                    // gutter / current-line highlight when this
1104                                    // tab gets entered after workspace restore.
1105                                    buf_state.show_line_numbers = false;
1106                                    buf_state.highlight_current_line = false;
1107                                }
1108                            }
1109                            SerializedTabRef::Unnamed(recovery_id) => {
1110                                if let Some(&buffer_id) = unnamed_buffers.get(recovery_id) {
1111                                    if !view_state.has_buffer(buffer_id) {
1112                                        view_state.add_buffer(buffer_id);
1113                                    }
1114                                    view_state.ensure_buffer_state(buffer_id);
1115                                }
1116                            }
1117                        }
1118                    }
1119
1120                    // If all saved tabs referenced deleted/missing files, open_buffers
1121                    // is now empty. Re-add the buffer that the split manager assigned to
1122                    // this split so the orphan cleanup won't remove a buffer the split
1123                    // manager still points to (#1278).
1124                    if view_state.open_buffers.is_empty() {
1125                        if let Some(buf) = split_buf_for_current {
1126                            view_state.add_buffer(buf);
1127                            view_state.ensure_buffer_state(buf);
1128                        }
1129                    }
1130
1131                    if let Some(active_idx) = split_state.active_tab_index {
1132                        if let Some(tab) = split_state.open_tabs.get(active_idx) {
1133                            active_buffer_id = match tab {
1134                                SerializedTabRef::File(rel) => path_to_buffer.get(rel).copied(),
1135                                SerializedTabRef::Terminal(index) => {
1136                                    terminal_buffers.get(index).copied()
1137                                }
1138                                SerializedTabRef::Unnamed(id) => unnamed_buffers.get(id).copied(),
1139                            };
1140                        }
1141                    }
1142                } else {
1143                    // Backward compatibility path using open_files/active_file_index
1144                    for rel_path in &split_state.open_files {
1145                        if let Some(&buffer_id) = path_to_buffer.get(rel_path) {
1146                            if !view_state.has_buffer(buffer_id) {
1147                                view_state.add_buffer(buffer_id);
1148                            }
1149                            view_state.ensure_buffer_state(buffer_id);
1150                        }
1151                    }
1152
1153                    let active_file_path =
1154                        split_state.open_files.get(split_state.active_file_index);
1155                    active_buffer_id =
1156                        active_file_path.and_then(|rel_path| path_to_buffer.get(rel_path).copied());
1157                }
1158
1159                // Restore cursor, scroll, view_mode, and compose_width for ALL buffers in file_states
1160                for (rel_path, file_state) in &split_state.file_states {
1161                    // Look up buffer by path, or by unnamed recovery ID
1162                    let rel_str = rel_path.to_string_lossy();
1163                    let buffer_id = if let Some(recovery_id) = rel_str.strip_prefix("__unnamed__") {
1164                        match unnamed_buffers.get(recovery_id).copied() {
1165                            Some(id) => id,
1166                            None => continue,
1167                        }
1168                    } else {
1169                        match path_to_buffer.get(rel_path).copied() {
1170                            Some(id) => id,
1171                            None => continue,
1172                        }
1173                    };
1174                    let max_pos = __buffers_mut
1175                        .get(&buffer_id)
1176                        .map(|b| b.buffer.len())
1177                        .unwrap_or(0);
1178
1179                    // Ensure keyed state exists for this buffer
1180                    let buf_state = view_state.ensure_buffer_state(buffer_id);
1181
1182                    let cursor_pos = file_state.cursor.position.min(max_pos);
1183                    buf_state.cursors.primary_mut().position = cursor_pos;
1184                    buf_state.cursors.primary_mut().anchor =
1185                        file_state.cursor.anchor.map(|a| a.min(max_pos));
1186                    buf_state.cursors.primary_mut().sticky_column = file_state.cursor.sticky_column;
1187
1188                    buf_state.viewport.top_byte = file_state.scroll.top_byte.min(max_pos);
1189                    buf_state.viewport.top_view_line_offset =
1190                        file_state.scroll.top_view_line_offset;
1191                    buf_state.viewport.left_column = file_state.scroll.left_column;
1192                    buf_state.viewport.set_skip_resize_sync();
1193
1194                    // Saved cursor and saved viewport are independent fields; if they
1195                    // were already out of sync at save time (cursor moved off-screen
1196                    // before the user closed) the restore re-creates an off-screen
1197                    // cursor that arrow keys can't escape (the wrap-mode early return
1198                    // in `viewport.rs::ensure_visible` no-ops for any cursor whose
1199                    // byte position is `>= viewport.top_byte`). Reconcile so the
1200                    // restored view always shows the cursor (#1689 follow-up).
1201                    if let Some(state) = __buffers_mut.get_mut(&buffer_id) {
1202                        super::navigation::reconcile_restored_buffer_view(
1203                            buf_state,
1204                            &mut state.buffer,
1205                        );
1206                    }
1207
1208                    // Restore per-buffer view mode and compose width
1209                    buf_state.view_mode = match file_state.view_mode {
1210                        SerializedViewMode::Source => ViewMode::Source,
1211                        SerializedViewMode::PageView => ViewMode::PageView,
1212                    };
1213                    buf_state.compose_width = file_state.compose_width;
1214                    buf_state.plugin_state = file_state.plugin_state.clone();
1215                    if let Some(state) = __buffers_mut.get_mut(&buffer_id) {
1216                        buf_state.folds.clear(&mut state.marker_list);
1217                        for fold in &file_state.folds {
1218                            // Resolve the stored line numbers against the current
1219                            // buffer content. If a header_text was recorded (issue
1220                            // #1568), validate — and if necessary relocate — the
1221                            // fold so it lands on the line it was actually meant
1222                            // for, even after an external edit shifted line
1223                            // numbers.
1224                            let Some(resolved_header) = resolve_fold_header_line(
1225                                &state.buffer,
1226                                fold.header_line,
1227                                fold.header_text.as_deref(),
1228                            ) else {
1229                                tracing::debug!(
1230                                    "Dropping stale fold: header_line={} no longer matches stored \
1231                             header_text after external edit",
1232                                    fold.header_line,
1233                                );
1234                                continue;
1235                            };
1236
1237                            // Adjust end_line by the same shift we applied to the header.
1238                            let shift = resolved_header as i64 - fold.header_line as i64;
1239                            let adjusted_end = (fold.end_line as i64 + shift).max(0) as usize;
1240                            let start_line = resolved_header.saturating_add(1);
1241                            let end_line = adjusted_end;
1242                            if start_line > end_line {
1243                                continue;
1244                            }
1245                            let Some(start_byte) = state.buffer.line_start_offset(start_line)
1246                            else {
1247                                continue;
1248                            };
1249                            let end_byte = state
1250                                .buffer
1251                                .line_start_offset(end_line.saturating_add(1))
1252                                .unwrap_or_else(|| state.buffer.len());
1253                            buf_state.folds.add(
1254                                &mut state.marker_list,
1255                                start_byte,
1256                                end_byte,
1257                                fold.placeholder.clone(),
1258                            );
1259                        }
1260                    }
1261
1262                    tracing::trace!(
1263                        "Restored keyed state for {:?}: cursor={}, top_byte={}, view_mode={:?}",
1264                        rel_path,
1265                        cursor_pos,
1266                        buf_state.viewport.top_byte,
1267                        buf_state.view_mode,
1268                    );
1269                }
1270
1271                // For buffers without saved file_state (e.g., terminals), apply split-level
1272                // view_mode/compose_width as fallback (backward compatibility)
1273                let restored_view_mode = match split_state.view_mode {
1274                    SerializedViewMode::Source => ViewMode::Source,
1275                    SerializedViewMode::PageView => ViewMode::PageView,
1276                };
1277
1278                if let Some(active_buf_id) = active_buffer_id {
1279                    // Switch the split to the active buffer
1280                    view_state.switch_buffer(active_buf_id);
1281
1282                    // If no per-buffer file_state was saved, apply split-level settings
1283                    let active_has_file_state = split_state.file_states.keys().any(|rel_path| {
1284                        path_to_buffer.get(rel_path).copied() == Some(active_buf_id)
1285                    });
1286                    if !active_has_file_state {
1287                        view_state.active_state_mut().view_mode = restored_view_mode.clone();
1288                        view_state.active_state_mut().compose_width = split_state.compose_width;
1289                    }
1290
1291                    // Cursors now live in SplitViewState, no need to sync to EditorState
1292                }
1293                view_state.tab_scroll_offset = split_state.tab_scroll_offset;
1294                active_buffer_id
1295            })
1296            .flatten();
1297
1298        // Set this buffer as active in the split (fires buffer_activated
1299        // hook). Done after the view_state borrow ends so we can take a
1300        // second &mut borrow on self.windows for the split manager.
1301        if let Some(active_buf_id) = active_buffer_id {
1302            self.buffers
1303                .split_manager_mut()
1304                .expect("active window must have a populated split layout")
1305                .set_split_buffer(current_split_id, active_buf_id);
1306        }
1307    }
1308
1309    fn restore_search_options(&mut self, opts: &SearchOptions) {
1310        self.search_case_sensitive = opts.case_sensitive;
1311        self.search_whole_word = opts.whole_word;
1312        self.search_use_regex = opts.use_regex;
1313        self.search_confirm_each = opts.confirm_each;
1314    }
1315
1316    fn restore_prompt_histories(&mut self, histories: &WorkspaceHistories) {
1317        tracing::debug!(
1318            "Restoring histories: {} search, {} replace, {} goto_line",
1319            histories.search.len(),
1320            histories.replace.len(),
1321            histories.goto_line.len()
1322        );
1323        for item in &histories.search {
1324            self.prompt_histories
1325                .entry("search".to_string())
1326                .or_default()
1327                .push(item.clone());
1328        }
1329        for item in &histories.replace {
1330            self.prompt_histories
1331                .entry("replace".to_string())
1332                .or_default()
1333                .push(item.clone());
1334        }
1335        for item in &histories.goto_line {
1336            self.prompt_histories
1337                .entry("goto_line".to_string())
1338                .or_default()
1339                .push(item.clone());
1340        }
1341    }
1342
1343    fn restore_file_explorer_settings(&mut self, fe: &FileExplorerState) {
1344        self.file_explorer_visible = fe.visible;
1345        self.file_explorer_width = fe.width;
1346        self.file_explorer_side = fe.side;
1347
1348        // Store pending settings (fixes #569); applied when explorer initialises (async).
1349        if fe.show_hidden {
1350            self.pending_file_explorer_show_hidden = Some(true);
1351        }
1352        if fe.show_gitignored {
1353            self.pending_file_explorer_show_gitignored = Some(true);
1354        }
1355
1356        // Keep key_context as Normal so the editor (not the explorer) has focus.
1357        if self.file_explorer_visible && self.file_explorer.is_none() {
1358            self.init_file_explorer();
1359        }
1360    }
1361
1362    /// Open every file referenced by the saved split states, returning a map
1363    /// from relative (or absolute) path to the new `BufferId`.
1364    fn open_workspace_files(
1365        &mut self,
1366        split_states: &HashMap<usize, SerializedSplitViewState>,
1367    ) -> HashMap<PathBuf, BufferId> {
1368        let file_paths = collect_file_paths_from_states(split_states);
1369        tracing::debug!(
1370            "Workspace has {} files to restore: {:?}",
1371            file_paths.len(),
1372            file_paths
1373        );
1374        let mut path_to_buffer: HashMap<PathBuf, BufferId> = HashMap::new();
1375        for rel_path in file_paths {
1376            let abs_path = self.root.join(&rel_path);
1377            tracing::trace!(
1378                "Checking file: {:?} (exists: {})",
1379                abs_path,
1380                abs_path.exists()
1381            );
1382            if abs_path.exists() {
1383                match self.open_file_internal(&abs_path) {
1384                    Ok(buffer_id) => {
1385                        tracing::debug!("Opened file {:?} as buffer {:?}", rel_path, buffer_id);
1386                        path_to_buffer.insert(rel_path, buffer_id);
1387                    }
1388                    Err(e) => tracing::warn!("Failed to open file {:?}: {}", abs_path, e),
1389                }
1390            } else {
1391                tracing::debug!("Skipping non-existent file: {:?}", abs_path);
1392            }
1393        }
1394        tracing::debug!("Opened {} files from workspace", path_to_buffer.len());
1395        path_to_buffer
1396    }
1397
1398    /// Restore files that live outside the working directory (stored as absolute paths).
1399    fn restore_external_files(
1400        &mut self,
1401        external_files: &[PathBuf],
1402        path_to_buffer: &mut HashMap<PathBuf, BufferId>,
1403    ) {
1404        if external_files.is_empty() {
1405            return;
1406        }
1407        tracing::debug!(
1408            "Restoring {} external files: {:?}",
1409            external_files.len(),
1410            external_files
1411        );
1412        for abs_path in external_files {
1413            if !abs_path.exists() {
1414                tracing::debug!("Skipping non-existent external file: {:?}", abs_path);
1415                continue;
1416            }
1417            match self.open_file_internal(abs_path) {
1418                Ok(buffer_id) => {
1419                    path_to_buffer.insert(abs_path.clone(), buffer_id);
1420                    tracing::debug!(
1421                        "Restored external file {:?} as buffer {:?}",
1422                        abs_path,
1423                        buffer_id
1424                    );
1425                }
1426                Err(e) => tracing::warn!("Failed to restore external file {:?}: {}", abs_path, e),
1427            }
1428        }
1429    }
1430
1431    /// Re-apply read-only flags for files that were locked in the saved session.
1432    /// Paths may be relative (under this window's `root`) or absolute.
1433    fn apply_read_only_flags(
1434        &mut self,
1435        read_only_files: &[PathBuf],
1436        path_to_buffer: &HashMap<PathBuf, BufferId>,
1437    ) {
1438        for ro_path in read_only_files {
1439            let buffer_id = path_to_buffer
1440                .get(ro_path)
1441                .copied()
1442                .or_else(|| path_to_buffer.get(&self.root.join(ro_path)).copied());
1443            if let Some(id) = buffer_id {
1444                self.mark_buffer_read_only(id, true);
1445            }
1446        }
1447    }
1448
1449    /// True when this window has any virtual buffer (Dashboard, plugin
1450    /// scratch buffers, etc.) — used by the save path to detect the
1451    /// Dashboard-only-quit case where the serializer produces an empty
1452    /// snapshot.
1453    pub(crate) fn has_any_virtual_buffer(&self) -> bool {
1454        self.buffer_metadata
1455            .values()
1456            .any(|m| matches!(m.kind, crate::app::types::BufferKind::Virtual { .. }))
1457    }
1458
1459    /// Persist per-file global state (cursor/scroll) for every file
1460    /// buffer in this window's splits.
1461    pub(crate) fn save_all_global_file_states(&self) {
1462        for (leaf_id, view_state) in self
1463            .buffers
1464            .splits()
1465            .map(|(_, vs)| vs)
1466            .expect("window must have a populated split layout")
1467        {
1468            let active_buffer = self
1469                .buffers
1470                .splits()
1471                .map(|(mgr, _)| mgr)
1472                .expect("window must have a populated split layout")
1473                .root()
1474                .get_leaves_with_rects(ratatui::layout::Rect::default())
1475                .into_iter()
1476                .find(|(sid, _, _)| *sid == *leaf_id)
1477                .map(|(_, buffer_id, _)| buffer_id);
1478
1479            if let Some(buffer_id) = active_buffer {
1480                self.save_buffer_file_state(buffer_id, view_state);
1481            }
1482        }
1483    }
1484
1485    /// Save per-file global state (cursor/scroll) for a specific buffer.
1486    fn save_buffer_file_state(&self, buffer_id: BufferId, view_state: &SplitViewState) {
1487        let abs_path = match self.buffer_metadata.get(&buffer_id) {
1488            Some(metadata) => match metadata.file_path() {
1489                Some(path) => path.to_path_buf(),
1490                None => return,
1491            },
1492            None => return,
1493        };
1494
1495        let primary_cursor = view_state.cursors.primary();
1496        let file_state = SerializedFileState {
1497            cursor: SerializedCursor {
1498                position: primary_cursor.position,
1499                anchor: primary_cursor.anchor,
1500                sticky_column: primary_cursor.sticky_column,
1501            },
1502            additional_cursors: view_state
1503                .cursors
1504                .iter()
1505                .skip(1)
1506                .map(|(_, cursor)| SerializedCursor {
1507                    position: cursor.position,
1508                    anchor: cursor.anchor,
1509                    sticky_column: cursor.sticky_column,
1510                })
1511                .collect(),
1512            scroll: SerializedScroll {
1513                top_byte: view_state.viewport.top_byte,
1514                top_view_line_offset: view_state.viewport.top_view_line_offset,
1515                left_column: view_state.viewport.left_column,
1516            },
1517            view_mode: Default::default(),
1518            compose_width: None,
1519            plugin_state: std::collections::HashMap::new(),
1520            folds: Vec::new(),
1521        };
1522
1523        PersistedFileWorkspace::save(&abs_path, file_state);
1524    }
1525
1526    /// Sync this window's active terminal visible screens to their
1527    /// backing files (so the snapshot captures complete terminal state).
1528    pub(crate) fn sync_terminal_backing_files(&self) {
1529        use std::io::BufWriter;
1530
1531        let terminals_to_sync: Vec<_> = self
1532            .terminal_buffers
1533            .values()
1534            .copied()
1535            .filter_map(|terminal_id| {
1536                self.terminal_backing_files
1537                    .get(&terminal_id)
1538                    .map(|path| (terminal_id, path.clone()))
1539            })
1540            .collect();
1541
1542        for (terminal_id, backing_path) in terminals_to_sync {
1543            if let Some(handle) = self.terminal_manager.get(terminal_id) {
1544                if let Ok(mut state) = handle.state.lock() {
1545                    // Persist any scrolled-off lines not yet in the file (e.g.
1546                    // lines a resize spilled into history on a terminal that was
1547                    // never viewed before quitting) so a restored session keeps
1548                    // the full scrollback.
1549                    if let Ok(mut file) = self
1550                        .resources
1551                        .authority
1552                        .filesystem
1553                        .open_file_for_append(&backing_path)
1554                    {
1555                        let mut writer = BufWriter::new(&mut *file);
1556                        if let Err(e) = state.flush_new_scrollback(&mut writer) {
1557                            tracing::warn!(
1558                                "Failed to flush terminal {:?} scrollback: {}",
1559                                terminal_id,
1560                                e
1561                            );
1562                        }
1563                    }
1564
1565                    if let Ok(mut file) = self
1566                        .resources
1567                        .authority
1568                        .filesystem
1569                        .open_file_for_append(&backing_path)
1570                    {
1571                        let mut writer = BufWriter::new(&mut *file);
1572                        if let Err(e) = state.append_visible_screen(&mut writer) {
1573                            tracing::warn!(
1574                                "Failed to sync terminal {:?} to backing file: {}",
1575                                terminal_id,
1576                                e
1577                            );
1578                        }
1579                    }
1580                }
1581            }
1582        }
1583    }
1584
1585    /// Create an unnamed (unsaved) buffer in this window from recovered
1586    /// hot-exit content. Window-scoped, no focus side-effects — the
1587    /// split-layout restore wires it into a tab afterwards.
1588    pub(crate) fn create_unnamed_recovery_buffer(
1589        &mut self,
1590        text: &str,
1591        recovery_id: String,
1592        display_name: String,
1593    ) -> BufferId {
1594        let buffer_id = self.alloc_buffer_id();
1595        let mut state = EditorState::new(
1596            self.terminal_width,
1597            self.terminal_height,
1598            self.resources.config.editor.large_file_threshold_bytes as usize,
1599            std::sync::Arc::clone(&self.resources.authority.filesystem),
1600        );
1601        state
1602            .margins
1603            .configure_for_line_numbers(self.resources.config.editor.line_numbers);
1604        state.buffer.set_default_line_ending(
1605            self.resources
1606                .config
1607                .editor
1608                .default_line_ending
1609                .to_line_ending(),
1610        );
1611        state.buffer.insert(0, text);
1612        state.buffer.set_modified(true);
1613        state.buffer.set_recovery_pending(false);
1614        self.buffers.insert(buffer_id, state);
1615
1616        let mut log = crate::model::event::EventLog::new();
1617        log.clear_saved_position();
1618        self.event_logs.insert(buffer_id, log);
1619
1620        let mut meta = crate::app::types::BufferMetadata::new();
1621        meta.recovery_id = Some(recovery_id);
1622        meta.display_name = display_name;
1623        self.buffer_metadata.insert(buffer_id, meta);
1624
1625        buffer_id
1626    }
1627
1628    /// Seed this window with the initial empty buffer + single-leaf split
1629    /// layout, if it doesn't already have a populated layout. Mirrors
1630    /// `Editor::build_fresh_layout_if_needed`, rooted on `self`.
1631    pub(crate) fn seed_initial_layout(&mut self) {
1632        if self.buffers.splits().is_some() && self.buffers.len() > 0 {
1633            return;
1634        }
1635        let buf = self.alloc_buffer_id();
1636        let mut state = EditorState::new(
1637            self.terminal_width,
1638            self.terminal_height,
1639            self.resources.config.editor.large_file_threshold_bytes as usize,
1640            std::sync::Arc::clone(&self.resources.authority.filesystem),
1641        );
1642        state
1643            .margins
1644            .configure_for_line_numbers(self.resources.config.editor.line_numbers);
1645        state.buffer.set_default_line_ending(
1646            self.resources
1647                .config
1648                .editor
1649                .default_line_ending
1650                .to_line_ending(),
1651        );
1652        let manager = crate::view::split::SplitManager::new(buf);
1653        let active_leaf = manager.active_split();
1654        let mut view_states = HashMap::new();
1655        view_states.insert(
1656            active_leaf,
1657            SplitViewState::with_buffer(self.terminal_width, self.terminal_height, buf),
1658        );
1659        self.buffers.set_splits((manager, view_states));
1660        self.buffers.insert(buf, state);
1661        self.buffer_metadata
1662            .insert(buf, crate::app::types::BufferMetadata::new());
1663        self.event_logs
1664            .insert(buf, crate::model::event::EventLog::new());
1665    }
1666
1667    /// Push a recovered buffer's full content to this window's LSP after
1668    /// an out-of-band hot-exit replay (the replay edits the buffer
1669    /// directly, bypassing the event log's `didChange`).
1670    pub(crate) fn sync_lsp_after_recovery_replay(&mut self, buffer_id: BufferId) {
1671        let Some(text) = self
1672            .buffers
1673            .get(&buffer_id)
1674            .and_then(|state| state.buffer.to_string())
1675        else {
1676            return;
1677        };
1678        let full_change = lsp_types::TextDocumentContentChangeEvent {
1679            range: None,
1680            range_length: None,
1681            text,
1682        };
1683        self.send_lsp_changes_for_buffer(buffer_id, vec![full_change]);
1684    }
1685
1686    /// Restore unnamed (unsaved) buffers into this window from their
1687    /// hot-exit recovery files (via the shared recovery service in
1688    /// `self.resources`). Returns a map from `recovery_id` to the new
1689    /// `BufferId`. No focus side-effects — the split-layout restore wires
1690    /// each buffer into a tab afterwards.
1691    fn restore_unnamed_buffers(
1692        &mut self,
1693        unnamed_buffers: &[UnnamedBufferRef],
1694    ) -> HashMap<String, BufferId> {
1695        let mut unnamed_buffer_map: HashMap<String, BufferId> = HashMap::new();
1696        if !self.resources.config.editor.hot_exit || unnamed_buffers.is_empty() {
1697            return unnamed_buffer_map;
1698        }
1699        tracing::debug!(
1700            "Restoring {} unnamed buffers from recovery",
1701            unnamed_buffers.len()
1702        );
1703        for unnamed_ref in unnamed_buffers {
1704            let entries = match self
1705                .resources
1706                .recovery_service
1707                .lock()
1708                .unwrap()
1709                .list_recoverable()
1710            {
1711                Ok(e) => e,
1712                Err(e) => {
1713                    tracing::warn!("Failed to list recovery entries: {}", e);
1714                    continue;
1715                }
1716            };
1717            let Some(entry) = entries.iter().find(|e| e.id == unnamed_ref.recovery_id) else {
1718                tracing::debug!(
1719                    "Recovery file not found for unnamed buffer {}",
1720                    unnamed_ref.recovery_id
1721                );
1722                continue;
1723            };
1724            let loaded = self
1725                .resources
1726                .recovery_service
1727                .lock()
1728                .unwrap()
1729                .load_recovery(entry);
1730            match loaded {
1731                Ok(crate::services::recovery::RecoveryResult::Recovered { content, .. }) => {
1732                    let text = String::from_utf8_lossy(&content).into_owned();
1733                    let buffer_id = self.create_unnamed_recovery_buffer(
1734                        &text,
1735                        unnamed_ref.recovery_id.clone(),
1736                        unnamed_ref.display_name.clone(),
1737                    );
1738                    unnamed_buffer_map.insert(unnamed_ref.recovery_id.clone(), buffer_id);
1739                    tracing::info!(
1740                        "Restored unnamed buffer '{}' (recovery_id={})",
1741                        unnamed_ref.display_name,
1742                        unnamed_ref.recovery_id
1743                    );
1744                }
1745                Ok(other) => {
1746                    tracing::warn!(
1747                        "Unexpected recovery result for unnamed buffer {}: {:?}",
1748                        unnamed_ref.recovery_id,
1749                        std::mem::discriminant(&other)
1750                    );
1751                }
1752                Err(e) => {
1753                    tracing::warn!(
1754                        "Failed to load recovery for unnamed buffer {}: {}",
1755                        unnamed_ref.recovery_id,
1756                        e
1757                    );
1758                }
1759            }
1760        }
1761        unnamed_buffer_map
1762    }
1763
1764    /// Replay hot-exit recovery data onto this window's file-backed
1765    /// buffers that were modified when the editor last exited (via the
1766    /// shared recovery service in `self.resources`).
1767    fn restore_hot_exit_changes(&mut self, path_to_buffer: &HashMap<PathBuf, BufferId>) {
1768        if !self.resources.config.editor.hot_exit {
1769            return;
1770        }
1771        let entries = self
1772            .resources
1773            .recovery_service
1774            .lock()
1775            .unwrap()
1776            .list_recoverable()
1777            .unwrap_or_default();
1778        if entries.is_empty() {
1779            return;
1780        }
1781        let buffer_ids: Vec<BufferId> = path_to_buffer.values().copied().collect();
1782        for buffer_id in buffer_ids {
1783            let file_path = self
1784                .buffers
1785                .get(&buffer_id)
1786                .and_then(|s| s.buffer.file_path().map(|p| p.to_path_buf()));
1787            let Some(file_path) = file_path else { continue };
1788
1789            let recovery_id = self
1790                .resources
1791                .recovery_service
1792                .lock()
1793                .unwrap()
1794                .get_buffer_id(Some(&file_path));
1795            let Some(entry) = entries.iter().find(|e| e.id == recovery_id) else {
1796                continue;
1797            };
1798            let loaded = self
1799                .resources
1800                .recovery_service
1801                .lock()
1802                .unwrap()
1803                .load_recovery(entry);
1804            match loaded {
1805                Ok(crate::services::recovery::RecoveryResult::Recovered { content, .. }) => {
1806                    let mut mutated = false;
1807                    if let Some(state) = self.buffers.get_mut(&buffer_id) {
1808                        let current_len = state.buffer.total_bytes();
1809                        let text = String::from_utf8_lossy(&content).into_owned();
1810                        let current = state.buffer.get_text_range_mut(0, current_len).ok();
1811                        let current_text = current
1812                            .as_ref()
1813                            .map(|b| String::from_utf8_lossy(b).into_owned());
1814                        if current_text.as_deref() != Some(&text) {
1815                            state.buffer.delete(0..current_len);
1816                            state.buffer.insert(0, &text);
1817                            state.buffer.set_modified(true);
1818                            state.buffer.set_recovery_pending(false);
1819                            mutated = true;
1820                            tracing::info!(
1821                                "Restored unsaved changes for {:?} from hot exit recovery",
1822                                file_path
1823                            );
1824                        }
1825                    }
1826                    if let Some(log) = self.event_logs.get_mut(&buffer_id) {
1827                        log.clear_saved_position();
1828                    }
1829                    if mutated {
1830                        self.sync_lsp_after_recovery_replay(buffer_id);
1831                    }
1832                }
1833                Ok(crate::services::recovery::RecoveryResult::RecoveredChunks {
1834                    chunks, ..
1835                }) => {
1836                    let mut mutated = false;
1837                    if let Some(state) = self.buffers.get_mut(&buffer_id) {
1838                        for chunk in chunks.into_iter().rev() {
1839                            let text = String::from_utf8_lossy(&chunk.content).into_owned();
1840                            if chunk.original_len > 0 {
1841                                state
1842                                    .buffer
1843                                    .delete(chunk.offset..chunk.offset + chunk.original_len);
1844                            }
1845                            state.buffer.insert(chunk.offset, &text);
1846                        }
1847                        state.buffer.set_modified(true);
1848                        state.buffer.set_recovery_pending(false);
1849                        mutated = true;
1850                        tracing::info!(
1851                            "Restored unsaved changes (chunked) for {:?} from hot exit recovery",
1852                            file_path
1853                        );
1854                    }
1855                    if let Some(log) = self.event_logs.get_mut(&buffer_id) {
1856                        log.clear_saved_position();
1857                    }
1858                    if mutated {
1859                        self.sync_lsp_after_recovery_replay(buffer_id);
1860                    }
1861                }
1862                Ok(crate::services::recovery::RecoveryResult::OriginalFileModified {
1863                    original_path,
1864                    ..
1865                }) => {
1866                    let name = original_path
1867                        .file_name()
1868                        .unwrap_or_default()
1869                        .to_string_lossy();
1870                    tracing::warn!("{} changed on disk; unsaved changes not restored", name);
1871                    self.set_status_message(format!(
1872                        "{} changed on disk; unsaved changes not restored",
1873                        name
1874                    ));
1875                }
1876                Ok(_) => {} // Corrupted, NotFound — skip
1877                Err(e) => {
1878                    tracing::debug!(
1879                        "Failed to load hot exit recovery for {:?}: {}",
1880                        file_path,
1881                        e
1882                    );
1883                }
1884            }
1885        }
1886    }
1887
1888    /// Apply a loaded workspace's layout onto this window — now fully
1889    /// window-scoped: search options, prompt histories, file-explorer
1890    /// settings, unnamed-buffer hot-exit recovery (before the split tree,
1891    /// which references those buffers), the opened files
1892    /// (`open_file_no_focus`, no focus side-effects), external + read-only
1893    /// files, terminals, the split tree + per-split view state, bookmarks,
1894    /// orphan cleanup, the restore summary, and finally hot-exit replay
1895    /// onto the opened file buffers. Recovery reaches the shared service
1896    /// via `self.resources.recovery_service`, so no `Editor` involvement
1897    /// is needed.
1898    ///
1899    /// The only steps that stay on `Editor::restore_workspace_for` are the
1900    /// genuinely editor-global ones: config overrides beyond
1901    /// `mouse_enabled`, plugin global state, and the active-window plugin
1902    /// snapshot + `buffer_activated`.
1903    pub(crate) fn apply_workspace_layout(
1904        &mut self,
1905        workspace: &Workspace,
1906        session_name: Option<&str>,
1907    ) {
1908        tracing::debug!(
1909            "Applying workspace layout with {} split states",
1910            workspace.split_states.len()
1911        );
1912
1913        // Window-local config override (the rest of the overrides mutate
1914        // the editor-global `Config` and are applied by the caller).
1915        if let Some(mouse_enabled) = workspace.config_overrides.mouse_enabled {
1916            self.mouse_enabled = mouse_enabled;
1917        }
1918
1919        self.restore_search_options(&workspace.search_options);
1920        self.restore_prompt_histories(&workspace.histories);
1921        self.restore_file_explorer_settings(&workspace.file_explorer);
1922
1923        // Unnamed-buffer recovery must precede the split layout (the tree
1924        // references those buffers).
1925        let unnamed_buffer_map = self.restore_unnamed_buffers(&workspace.unnamed_buffers);
1926
1927        let mut path_to_buffer = self.open_workspace_files(&workspace.split_states);
1928        self.restore_external_files(&workspace.external_files, &mut path_to_buffer);
1929        self.apply_read_only_flags(&workspace.read_only_files, &path_to_buffer);
1930
1931        let terminal_buffer_map = self.restore_terminals_from_workspace(&workspace.terminals);
1932
1933        let mut split_id_map: HashMap<usize, SplitId> = HashMap::new();
1934        self.restore_split_node(
1935            &workspace.split_layout,
1936            &path_to_buffer,
1937            &terminal_buffer_map,
1938            &unnamed_buffer_map,
1939            &workspace.split_states,
1940            &mut split_id_map,
1941            true,
1942        );
1943
1944        if let Some(&new_active_split) = split_id_map.get(&workspace.active_split_id) {
1945            self.buffers
1946                .split_manager_mut()
1947                .expect("window must have a populated split layout")
1948                .set_active_split(LeafId(new_active_split));
1949        }
1950
1951        self.restore_bookmarks_from_workspace(&workspace.bookmarks, &path_to_buffer);
1952        self.clean_orphaned_buffers();
1953        self.log_restore_summary(session_name);
1954
1955        // Replay hot-exit changes onto the file-backed buffers we opened.
1956        self.restore_hot_exit_changes(&path_to_buffer);
1957    }
1958
1959    /// Build a `Window` directly from a persisted `Workspace`: construct
1960    /// a fresh window, seed its initial layout, then apply the workspace
1961    /// layout into it. The realized "restore is a Window factory" design —
1962    /// moving the `open_file` core and the recovery service onto `Window`
1963    /// removed the prior blockers that kept restore on `Editor`.
1964    pub(crate) fn from_workspace(
1965        id: fresh_core::WindowId,
1966        label: impl Into<String>,
1967        root: PathBuf,
1968        resources: crate::app::window_resources::WindowResources,
1969        workspace: &Workspace,
1970    ) -> Self {
1971        let mut window = Self::new(id, label, root, resources);
1972        window.seed_initial_layout();
1973        window.apply_workspace_layout(workspace, None);
1974        window
1975    }
1976
1977    /// Snapshot THIS window's restorable state into a `Workspace`,
1978    /// rooted at `self.root` and reading only window-owned state +
1979    /// `self.resources`. The inverse of restore. `plugin_global_state`
1980    /// is left empty here — it is editor-global, so the `Editor` wrapper
1981    /// fills it in (see `Editor::capture_workspace`).
1982    pub(crate) fn capture_workspace(&self) -> Workspace {
1983        tracing::debug!("Capturing workspace for {:?}", self.root);
1984
1985        let mut terminals = Vec::new();
1986        let mut terminal_indices: HashMap<TerminalId, usize> = HashMap::new();
1987        let mut seen = HashSet::new();
1988        for terminal_id in self.terminal_buffers.values().copied() {
1989            if seen.insert(terminal_id) {
1990                if self.ephemeral_terminals.contains(&terminal_id) {
1991                    continue;
1992                }
1993                let idx = terminals.len();
1994                terminal_indices.insert(terminal_id, idx);
1995                let handle = self.terminal_manager.get(terminal_id);
1996                let (cols, rows) = handle
1997                    .map(|h| h.size())
1998                    .unwrap_or((self.terminal_width, self.terminal_height));
1999                let cwd = handle.and_then(|h| h.cwd());
2000                let shell = handle
2001                    .map(|h| h.shell().to_string())
2002                    .unwrap_or_else(crate::services::terminal::detect_shell);
2003                let log_path = self
2004                    .terminal_log_files
2005                    .get(&terminal_id)
2006                    .cloned()
2007                    .unwrap_or_else(|| {
2008                        let root = self.resources.dir_context.terminal_dir_for(&self.root);
2009                        root.join(format!("fresh-terminal-{}.log", terminal_id.0))
2010                    });
2011                let backing_path = self
2012                    .terminal_backing_files
2013                    .get(&terminal_id)
2014                    .cloned()
2015                    .unwrap_or_else(|| {
2016                        let root = self.resources.dir_context.terminal_dir_for(&self.root);
2017                        root.join(format!("fresh-terminal-{}.txt", terminal_id.0))
2018                    });
2019
2020                terminals.push(SerializedTerminalWorkspace {
2021                    terminal_index: idx,
2022                    cwd,
2023                    shell,
2024                    cols,
2025                    rows,
2026                    log_path,
2027                    backing_path,
2028                });
2029            }
2030        }
2031
2032        let (mgr, view_states) = self
2033            .buffers
2034            .splits()
2035            .expect("window must have a populated split layout");
2036
2037        let split_layout = serialize_split_node(
2038            mgr.root(),
2039            &self.buffer_metadata,
2040            &self.root,
2041            &self.terminal_buffers,
2042            &terminal_indices,
2043            mgr.labels(),
2044        );
2045
2046        let active_buffers: HashMap<LeafId, BufferId> = mgr
2047            .root()
2048            .get_leaves_with_rects(ratatui::layout::Rect::default())
2049            .into_iter()
2050            .map(|(leaf_id, buffer_id, _)| (leaf_id, buffer_id))
2051            .collect();
2052
2053        let mut split_states = HashMap::new();
2054        for (leaf_id, view_state) in view_states {
2055            let active_buffer = active_buffers.get(leaf_id).copied();
2056            let serialized = serialize_split_view_state(
2057                view_state,
2058                self.buffers.as_map(),
2059                &self.buffer_metadata,
2060                &self.root,
2061                active_buffer,
2062                &self.terminal_buffers,
2063                &terminal_indices,
2064            );
2065            split_states.insert(leaf_id.0 .0, serialized);
2066        }
2067
2068        let file_explorer = if let Some(explorer) = self.file_explorer.as_ref() {
2069            let expanded_dirs = get_expanded_dirs(explorer, &self.root);
2070            FileExplorerState {
2071                visible: self.file_explorer_visible,
2072                width: self.file_explorer_width,
2073                side: self.file_explorer_side,
2074                expanded_dirs,
2075                scroll_offset: explorer.get_scroll_offset(),
2076                show_hidden: explorer.ignore_patterns().show_hidden(),
2077                show_gitignored: explorer.ignore_patterns().show_gitignored(),
2078            }
2079        } else {
2080            FileExplorerState {
2081                visible: self.file_explorer_visible,
2082                width: self.file_explorer_width,
2083                side: self.file_explorer_side,
2084                expanded_dirs: Vec::new(),
2085                scroll_offset: 0,
2086                show_hidden: false,
2087                show_gitignored: false,
2088            }
2089        };
2090
2091        let cfg = &self.resources.config.editor;
2092        let config_overrides = WorkspaceConfigOverrides {
2093            line_numbers: Some(cfg.line_numbers),
2094            relative_line_numbers: Some(cfg.relative_line_numbers),
2095            line_wrap: Some(cfg.line_wrap),
2096            syntax_highlighting: Some(cfg.syntax_highlighting),
2097            enable_inlay_hints: Some(cfg.enable_inlay_hints),
2098            mouse_enabled: Some(self.mouse_enabled),
2099            menu_bar_hidden: None,
2100        };
2101
2102        let histories = WorkspaceHistories {
2103            search: self
2104                .prompt_histories
2105                .get("search")
2106                .map(|h| h.items().to_vec())
2107                .unwrap_or_default(),
2108            replace: self
2109                .prompt_histories
2110                .get("replace")
2111                .map(|h| h.items().to_vec())
2112                .unwrap_or_default(),
2113            command_palette: Vec::new(),
2114            goto_line: self
2115                .prompt_histories
2116                .get("goto_line")
2117                .map(|h| h.items().to_vec())
2118                .unwrap_or_default(),
2119            open_file: Vec::new(),
2120        };
2121
2122        let search_options = SearchOptions {
2123            case_sensitive: self.search_case_sensitive,
2124            whole_word: self.search_whole_word,
2125            use_regex: self.search_use_regex,
2126            confirm_each: self.search_confirm_each,
2127        };
2128
2129        let bookmarks = serialize_bookmarks(&self.bookmarks, &self.buffer_metadata, &self.root);
2130
2131        let external_files: Vec<PathBuf> = self
2132            .buffer_metadata
2133            .values()
2134            .filter(|meta| !meta.hidden_from_tabs && !meta.is_virtual())
2135            .filter_map(|meta| meta.file_path())
2136            .filter(|abs_path| abs_path.strip_prefix(&self.root).is_err())
2137            .cloned()
2138            .collect();
2139
2140        let read_only_files: Vec<PathBuf> = self
2141            .buffer_metadata
2142            .values()
2143            .filter(|meta| !meta.hidden_from_tabs && !meta.is_virtual())
2144            .filter(|meta| meta.read_only)
2145            .filter_map(|meta| meta.file_path().cloned())
2146            .filter(|p| !p.as_os_str().is_empty())
2147            .map(|p| {
2148                p.strip_prefix(&self.root)
2149                    .map(|rel| rel.to_path_buf())
2150                    .unwrap_or(p)
2151            })
2152            .collect();
2153
2154        let unnamed_buffers: Vec<UnnamedBufferRef> = if self.resources.config.editor.hot_exit {
2155            self.buffer_metadata
2156                .iter()
2157                .filter_map(|(buffer_id, meta)| {
2158                    let path = meta.file_path()?;
2159                    if !path.as_os_str().is_empty() {
2160                        return None;
2161                    }
2162                    if meta.hidden_from_tabs || meta.is_virtual() {
2163                        return None;
2164                    }
2165                    let state = self.buffers.get(buffer_id)?;
2166                    if state.buffer.total_bytes() == 0 {
2167                        return None;
2168                    }
2169                    let recovery_id = meta.recovery_id.clone()?;
2170                    Some(UnnamedBufferRef {
2171                        recovery_id,
2172                        display_name: meta.display_name.clone(),
2173                    })
2174                })
2175                .collect()
2176        } else {
2177            Vec::new()
2178        };
2179
2180        Workspace {
2181            version: WORKSPACE_VERSION,
2182            working_dir: self.root.clone(),
2183            split_layout,
2184            active_split_id: SplitId::from(mgr.active_split()).0,
2185            split_states,
2186            config_overrides,
2187            file_explorer,
2188            histories,
2189            search_options,
2190            bookmarks,
2191            terminals,
2192            external_files,
2193            read_only_files,
2194            unnamed_buffers,
2195            plugin_global_state: HashMap::new(),
2196            saved_at: std::time::SystemTime::now()
2197                .duration_since(std::time::UNIX_EPOCH)
2198                .unwrap_or_default()
2199                .as_secs(),
2200            // Session identity (windows.json is gone — the per-dir
2201            // workspace file is the sole record).
2202            label: Some(self.label.clone()),
2203            session_plugin_state: self.plugin_state.clone(),
2204        }
2205    }
2206}
2207
2208/// Helper: Get the buffer ID from the first leaf node in a split tree
2209fn get_first_leaf_buffer(
2210    node: &SerializedSplitNode,
2211    path_to_buffer: &HashMap<PathBuf, BufferId>,
2212    terminal_buffers: &HashMap<usize, BufferId>,
2213    unnamed_buffers: &HashMap<String, BufferId>,
2214) -> Option<BufferId> {
2215    match node {
2216        SerializedSplitNode::Leaf {
2217            file_path,
2218            unnamed_recovery_id,
2219            ..
2220        } => file_path
2221            .as_ref()
2222            .and_then(|p| path_to_buffer.get(p).copied())
2223            .or_else(|| {
2224                unnamed_recovery_id
2225                    .as_ref()
2226                    .and_then(|id| unnamed_buffers.get(id).copied())
2227            }),
2228        SerializedSplitNode::Terminal { terminal_index, .. } => {
2229            terminal_buffers.get(terminal_index).copied()
2230        }
2231        SerializedSplitNode::Split { first, .. } => {
2232            get_first_leaf_buffer(first, path_to_buffer, terminal_buffers, unnamed_buffers)
2233        }
2234    }
2235}
2236
2237// ============================================================================
2238// Serialization helpers
2239// ============================================================================
2240
2241fn serialize_split_node(
2242    node: &SplitNode,
2243    buffer_metadata: &HashMap<BufferId, super::types::BufferMetadata>,
2244    working_dir: &Path,
2245    terminal_buffers: &HashMap<BufferId, TerminalId>,
2246    terminal_indices: &HashMap<TerminalId, usize>,
2247    split_labels: &HashMap<SplitId, String>,
2248) -> SerializedSplitNode {
2249    serialize_split_node_pruned(
2250        node,
2251        buffer_metadata,
2252        working_dir,
2253        terminal_buffers,
2254        terminal_indices,
2255        split_labels,
2256    )
2257    .unwrap_or({
2258        // Entire tree was virtual buffers — nothing to persist.  Fall back to
2259        // an empty [No Name] leaf so the restored workspace is still valid.
2260        SerializedSplitNode::Leaf {
2261            file_path: None,
2262            split_id: 0,
2263            label: None,
2264            unnamed_recovery_id: None,
2265            role: None,
2266        }
2267    })
2268}
2269
2270/// Like `serialize_split_node` but returns `None` for subtrees that only
2271/// contain transient virtual buffers (e.g. `*Search/Replace*` panels).
2272/// Virtual buffers can't be rebuilt from disk, so persisting their split
2273/// would leave an empty or mis-attributed pane on restore (see bug #5).
2274/// When one child of a Split prunes away, the surviving child is hoisted in
2275/// place of the whole Split node.
2276fn serialize_split_node_pruned(
2277    node: &SplitNode,
2278    buffer_metadata: &HashMap<BufferId, super::types::BufferMetadata>,
2279    working_dir: &Path,
2280    terminal_buffers: &HashMap<BufferId, TerminalId>,
2281    terminal_indices: &HashMap<TerminalId, usize>,
2282    split_labels: &HashMap<SplitId, String>,
2283) -> Option<SerializedSplitNode> {
2284    match node {
2285        SplitNode::Grouped { layout, .. } => {
2286            // Grouped nodes are rebuilt by plugins on load; serialize just
2287            // the inner layout so the split tree structure is preserved
2288            // without the group wrapper.
2289            serialize_split_node_pruned(
2290                layout,
2291                buffer_metadata,
2292                working_dir,
2293                terminal_buffers,
2294                terminal_indices,
2295                split_labels,
2296            )
2297        }
2298        SplitNode::Leaf {
2299            buffer_id,
2300            split_id,
2301            role,
2302        } => {
2303            let raw_split_id: SplitId = (*split_id).into();
2304            let label = split_labels.get(&raw_split_id).cloned();
2305            let role = *role;
2306
2307            if let Some(terminal_id) = terminal_buffers.get(buffer_id) {
2308                if let Some(index) = terminal_indices.get(terminal_id) {
2309                    return Some(SerializedSplitNode::Terminal {
2310                        terminal_index: *index,
2311                        split_id: raw_split_id.0,
2312                        label,
2313                        role,
2314                    });
2315                }
2316            }
2317
2318            let meta = buffer_metadata.get(buffer_id);
2319
2320            // Virtual buffers (e.g. the *Search/Replace* panel) have no
2321            // persistent identity — drop them and let the parent Split node
2322            // collapse to the sibling.
2323            if meta.map(|m| m.is_virtual()).unwrap_or(false) {
2324                return None;
2325            }
2326
2327            let file_path = meta.and_then(|m| m.file_path()).and_then(|abs_path| {
2328                if abs_path.as_os_str().is_empty() {
2329                    None // unnamed buffer
2330                } else {
2331                    abs_path
2332                        .strip_prefix(working_dir)
2333                        .ok()
2334                        .map(|p| p.to_path_buf())
2335                }
2336            });
2337
2338            // For unnamed buffers, emit their recovery ID so workspace restore
2339            // can load content from recovery files
2340            let unnamed_recovery_id = if file_path.is_none() {
2341                meta.and_then(|m| m.recovery_id.clone())
2342            } else {
2343                None
2344            };
2345
2346            Some(SerializedSplitNode::Leaf {
2347                file_path,
2348                split_id: raw_split_id.0,
2349                label,
2350                unnamed_recovery_id,
2351                role,
2352            })
2353        }
2354        SplitNode::Split {
2355            direction,
2356            first,
2357            second,
2358            ratio,
2359            split_id,
2360            ..
2361        } => {
2362            let raw_split_id: SplitId = (*split_id).into();
2363            let first = serialize_split_node_pruned(
2364                first,
2365                buffer_metadata,
2366                working_dir,
2367                terminal_buffers,
2368                terminal_indices,
2369                split_labels,
2370            );
2371            let second = serialize_split_node_pruned(
2372                second,
2373                buffer_metadata,
2374                working_dir,
2375                terminal_buffers,
2376                terminal_indices,
2377                split_labels,
2378            );
2379            match (first, second) {
2380                (Some(f), Some(s)) => Some(SerializedSplitNode::Split {
2381                    direction: match direction {
2382                        SplitDirection::Horizontal => SerializedSplitDirection::Horizontal,
2383                        SplitDirection::Vertical => SerializedSplitDirection::Vertical,
2384                    },
2385                    first: Box::new(f),
2386                    second: Box::new(s),
2387                    ratio: *ratio,
2388                    split_id: raw_split_id.0,
2389                }),
2390                // One side was a virtual-buffer-only subtree — collapse to
2391                // the surviving sibling.
2392                (Some(only), None) | (None, Some(only)) => Some(only),
2393                (None, None) => None,
2394            }
2395        }
2396    }
2397}
2398
2399fn serialize_split_view_state(
2400    view_state: &crate::view::split::SplitViewState,
2401    buffers: &HashMap<BufferId, EditorState>,
2402    buffer_metadata: &HashMap<BufferId, super::types::BufferMetadata>,
2403    working_dir: &Path,
2404    active_buffer: Option<BufferId>,
2405    terminal_buffers: &HashMap<BufferId, TerminalId>,
2406    terminal_indices: &HashMap<TerminalId, usize>,
2407) -> SerializedSplitViewState {
2408    let mut open_tabs = Vec::new();
2409    let mut open_files = Vec::new();
2410    let mut active_tab_index = None;
2411
2412    // Only serialize buffer tabs; group tabs are rebuilt by plugins on load.
2413    for buffer_id in view_state.buffer_tab_ids() {
2414        let buffer_id = &buffer_id;
2415        let tab_index = open_tabs.len();
2416        if let Some(terminal_id) = terminal_buffers.get(buffer_id) {
2417            if let Some(idx) = terminal_indices.get(terminal_id) {
2418                open_tabs.push(SerializedTabRef::Terminal(*idx));
2419                if Some(*buffer_id) == active_buffer {
2420                    active_tab_index = Some(tab_index);
2421                }
2422                continue;
2423            }
2424        }
2425
2426        if let Some(meta) = buffer_metadata.get(buffer_id) {
2427            if let Some(abs_path) = meta.file_path() {
2428                if abs_path.as_os_str().is_empty() {
2429                    // Unnamed buffer - reference by recovery ID
2430                    if let Some(ref recovery_id) = meta.recovery_id {
2431                        open_tabs.push(SerializedTabRef::Unnamed(recovery_id.clone()));
2432                        if Some(*buffer_id) == active_buffer {
2433                            active_tab_index = Some(tab_index);
2434                        }
2435                    }
2436                } else if let Ok(rel_path) = abs_path.strip_prefix(working_dir) {
2437                    open_tabs.push(SerializedTabRef::File(rel_path.to_path_buf()));
2438                    open_files.push(rel_path.to_path_buf());
2439                    if Some(*buffer_id) == active_buffer {
2440                        active_tab_index = Some(tab_index);
2441                    }
2442                } else {
2443                    // External file (outside working_dir) - store absolute path
2444                    open_tabs.push(SerializedTabRef::File(abs_path.to_path_buf()));
2445                    if Some(*buffer_id) == active_buffer {
2446                        active_tab_index = Some(tab_index);
2447                    }
2448                }
2449            }
2450        }
2451    }
2452
2453    // Derive active_file_index for backward compatibility
2454    let active_file_index = active_tab_index
2455        .and_then(|idx| open_tabs.get(idx))
2456        .and_then(|tab| match tab {
2457            SerializedTabRef::File(path) => {
2458                Some(open_files.iter().position(|p| p == path).unwrap_or(0))
2459            }
2460            _ => None,
2461        })
2462        .unwrap_or(0);
2463
2464    // Serialize file states for ALL buffers in keyed_states (not just the active one)
2465    let mut file_states = HashMap::new();
2466    for (buffer_id, buf_state) in &view_state.keyed_states {
2467        let Some(meta) = buffer_metadata.get(buffer_id) else {
2468            continue;
2469        };
2470        let Some(abs_path) = meta.file_path() else {
2471            continue;
2472        };
2473
2474        // Determine the key for this buffer's state
2475        let state_key = if abs_path.as_os_str().is_empty() {
2476            // Unnamed buffer - use recovery ID as key
2477            if let Some(ref recovery_id) = meta.recovery_id {
2478                PathBuf::from(format!("__unnamed__{}", recovery_id))
2479            } else {
2480                continue;
2481            }
2482        } else if let Ok(rp) = abs_path.strip_prefix(working_dir) {
2483            rp.to_path_buf()
2484        } else {
2485            // External file - use absolute path as key
2486            abs_path.to_path_buf()
2487        };
2488
2489        let primary_cursor = buf_state.cursors.primary();
2490        let folds = buffers
2491            .get(buffer_id)
2492            .map(|state| {
2493                buf_state
2494                    .folds
2495                    .collapsed_line_ranges(&state.buffer, &state.marker_list)
2496                    .into_iter()
2497                    .map(|range| SerializedFoldRange {
2498                        header_line: range.header_line,
2499                        end_line: range.end_line,
2500                        placeholder: range.placeholder,
2501                        header_text: range.header_text,
2502                    })
2503                    .collect::<Vec<_>>()
2504            })
2505            .unwrap_or_default();
2506
2507        file_states.insert(
2508            state_key,
2509            SerializedFileState {
2510                cursor: SerializedCursor {
2511                    position: primary_cursor.position,
2512                    anchor: primary_cursor.anchor,
2513                    sticky_column: primary_cursor.sticky_column,
2514                },
2515                additional_cursors: buf_state
2516                    .cursors
2517                    .iter()
2518                    .skip(1) // Skip primary
2519                    .map(|(_, cursor)| SerializedCursor {
2520                        position: cursor.position,
2521                        anchor: cursor.anchor,
2522                        sticky_column: cursor.sticky_column,
2523                    })
2524                    .collect(),
2525                scroll: SerializedScroll {
2526                    top_byte: buf_state.viewport.top_byte,
2527                    top_view_line_offset: buf_state.viewport.top_view_line_offset,
2528                    left_column: buf_state.viewport.left_column,
2529                },
2530                view_mode: match buf_state.view_mode {
2531                    ViewMode::Source => SerializedViewMode::Source,
2532                    ViewMode::PageView => SerializedViewMode::PageView,
2533                },
2534                compose_width: buf_state.compose_width,
2535                plugin_state: buf_state.plugin_state.clone(),
2536                folds,
2537            },
2538        );
2539    }
2540
2541    // Active buffer's view_mode/compose_width for the split-level fields (backward compat)
2542    let active_view_mode = active_buffer
2543        .and_then(|id| view_state.keyed_states.get(&id))
2544        .map(|bs| match bs.view_mode {
2545            ViewMode::Source => SerializedViewMode::Source,
2546            ViewMode::PageView => SerializedViewMode::PageView,
2547        })
2548        .unwrap_or(SerializedViewMode::Source);
2549    let active_compose_width = active_buffer
2550        .and_then(|id| view_state.keyed_states.get(&id))
2551        .and_then(|bs| bs.compose_width);
2552
2553    SerializedSplitViewState {
2554        open_tabs,
2555        active_tab_index,
2556        open_files,
2557        active_file_index,
2558        file_states,
2559        tab_scroll_offset: view_state.tab_scroll_offset,
2560        view_mode: active_view_mode,
2561        compose_width: active_compose_width,
2562    }
2563}
2564
2565fn serialize_bookmarks(
2566    bookmarks: &BookmarkState,
2567    buffer_metadata: &HashMap<BufferId, super::types::BufferMetadata>,
2568    working_dir: &Path,
2569) -> HashMap<char, SerializedBookmark> {
2570    bookmarks
2571        .iter()
2572        .filter_map(|(key, bookmark)| {
2573            buffer_metadata
2574                .get(&bookmark.buffer_id)
2575                .and_then(|meta| meta.file_path())
2576                .and_then(|abs_path| {
2577                    abs_path.strip_prefix(working_dir).ok().map(|rel_path| {
2578                        (
2579                            key,
2580                            SerializedBookmark {
2581                                file_path: rel_path.to_path_buf(),
2582                                position: bookmark.position,
2583                            },
2584                        )
2585                    })
2586                })
2587        })
2588        .collect()
2589}
2590
2591/// Collect all unique file paths from split_states
2592fn collect_file_paths_from_states(
2593    split_states: &HashMap<usize, SerializedSplitViewState>,
2594) -> Vec<PathBuf> {
2595    let mut paths = Vec::new();
2596    for state in split_states.values() {
2597        if !state.open_tabs.is_empty() {
2598            for tab in &state.open_tabs {
2599                if let SerializedTabRef::File(path) = tab {
2600                    if !paths.contains(path) {
2601                        paths.push(path.clone());
2602                    }
2603                }
2604            }
2605        } else {
2606            for path in &state.open_files {
2607                if !paths.contains(path) {
2608                    paths.push(path.clone());
2609                }
2610            }
2611        }
2612    }
2613    paths
2614}
2615
2616/// Get list of expanded directories from a FileTreeView
2617fn get_expanded_dirs(
2618    explorer: &crate::view::file_tree::FileTreeView,
2619    working_dir: &Path,
2620) -> Vec<PathBuf> {
2621    let mut expanded = Vec::new();
2622    let tree = explorer.tree();
2623
2624    // Iterate through all nodes and collect expanded directories
2625    for node in tree.all_nodes() {
2626        if node.is_expanded() && node.is_dir() {
2627            // Get the path and make it relative to working_dir
2628            if let Ok(rel_path) = node.entry.path.strip_prefix(working_dir) {
2629                expanded.push(rel_path.to_path_buf());
2630            }
2631        }
2632    }
2633
2634    expanded
2635}