Skip to main content

fresh/app/
workspace.rs

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