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