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