Skip to main content

fresh/app/
session.rs

1//! Session persistence integration for the Editor
2//!
3//! This module provides conversion between live Editor state and serialized Session data.
4//!
5//! # Role in Incremental Streaming Architecture
6//!
7//! This module handles session save/restore for terminals.
8//! See `crate::services::terminal` for the full architecture diagram.
9//!
10//! ## Session Save
11//!
12//! [`Editor::save_session`] calls [`Editor::sync_all_terminal_backing_files`] to ensure
13//! all terminal backing files contain complete state (scrollback + visible screen)
14//! before serializing session metadata.
15//!
16//! ## Session Restore
17//!
18//! [`Editor::restore_terminal_from_session`] 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 session 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, SplitDirection, SplitId};
32use crate::services::terminal::TerminalId;
33use crate::session::{
34    FileExplorerState, PersistedFileSession, SearchOptions, SerializedBookmark, SerializedCursor,
35    SerializedFileState, SerializedScroll, SerializedSplitDirection, SerializedSplitNode,
36    SerializedSplitViewState, SerializedTabRef, SerializedTerminalSession, SerializedViewMode,
37    Session, SessionConfigOverrides, SessionError, SessionHistories, SESSION_VERSION,
38};
39use crate::state::ViewMode;
40use crate::view::split::{SplitNode, SplitViewState};
41
42use super::types::Bookmark;
43use super::Editor;
44
45/// Session persistence state tracker
46///
47/// Tracks dirty state and handles debounced saving for crash resistance.
48pub struct SessionTracker {
49    /// Whether session has unsaved changes
50    dirty: bool,
51    /// Last save time
52    last_save: Instant,
53    /// Minimum interval between saves (debounce)
54    save_interval: std::time::Duration,
55    /// Whether session persistence is enabled
56    enabled: bool,
57}
58
59impl SessionTracker {
60    /// Create a new session tracker
61    pub fn new(enabled: bool) -> Self {
62        Self {
63            dirty: false,
64            last_save: Instant::now(),
65            save_interval: std::time::Duration::from_secs(5),
66            enabled,
67        }
68    }
69
70    /// Check if session tracking is enabled
71    pub fn is_enabled(&self) -> bool {
72        self.enabled
73    }
74
75    /// Mark session as needing save
76    pub fn mark_dirty(&mut self) {
77        if self.enabled {
78            self.dirty = true;
79        }
80    }
81
82    /// Check if a save is needed and enough time has passed
83    pub fn should_save(&self) -> bool {
84        self.enabled && self.dirty && self.last_save.elapsed() >= self.save_interval
85    }
86
87    /// Record that a save was performed
88    pub fn record_save(&mut self) {
89        self.dirty = false;
90        self.last_save = Instant::now();
91    }
92
93    /// Check if there are unsaved changes (for shutdown)
94    pub fn is_dirty(&self) -> bool {
95        self.dirty
96    }
97}
98
99impl Editor {
100    /// Capture current editor state into a Session
101    pub fn capture_session(&self) -> Session {
102        tracing::debug!("Capturing session for {:?}", self.working_dir);
103
104        // Collect terminal metadata for session restore
105        let mut terminals = Vec::new();
106        let mut terminal_indices: HashMap<TerminalId, usize> = HashMap::new();
107        let mut seen = HashSet::new();
108        for terminal_id in self.terminal_buffers.values().copied() {
109            if seen.insert(terminal_id) {
110                let idx = terminals.len();
111                terminal_indices.insert(terminal_id, idx);
112                let handle = self.terminal_manager.get(terminal_id);
113                let (cols, rows) = handle
114                    .map(|h| h.size())
115                    .unwrap_or((self.terminal_width, self.terminal_height));
116                let cwd = handle.and_then(|h| h.cwd());
117                let shell = handle
118                    .map(|h| h.shell().to_string())
119                    .unwrap_or_else(crate::services::terminal::detect_shell);
120                let log_path = self
121                    .terminal_log_files
122                    .get(&terminal_id)
123                    .cloned()
124                    .unwrap_or_else(|| {
125                        let root = self.dir_context.terminal_dir_for(&self.working_dir);
126                        root.join(format!("fresh-terminal-{}.log", terminal_id.0))
127                    });
128                let backing_path = self
129                    .terminal_backing_files
130                    .get(&terminal_id)
131                    .cloned()
132                    .unwrap_or_else(|| {
133                        let root = self.dir_context.terminal_dir_for(&self.working_dir);
134                        root.join(format!("fresh-terminal-{}.txt", terminal_id.0))
135                    });
136
137                terminals.push(SerializedTerminalSession {
138                    terminal_index: idx,
139                    cwd,
140                    shell,
141                    cols,
142                    rows,
143                    log_path,
144                    backing_path,
145                });
146            }
147        }
148
149        let split_layout = serialize_split_node(
150            self.split_manager.root(),
151            &self.buffer_metadata,
152            &self.working_dir,
153            &self.terminal_buffers,
154            &terminal_indices,
155        );
156
157        // Build a map of split_id -> active_buffer_id from the split tree
158        // This tells us which buffer's cursor/scroll to save for each split
159        let active_buffers: HashMap<SplitId, BufferId> = self
160            .split_manager
161            .root()
162            .get_leaves_with_rects(ratatui::layout::Rect::default())
163            .into_iter()
164            .map(|(split_id, buffer_id, _)| (split_id, buffer_id))
165            .collect();
166
167        let mut split_states = HashMap::new();
168        for (split_id, view_state) in &self.split_view_states {
169            let active_buffer = active_buffers.get(split_id).copied();
170            let serialized = serialize_split_view_state(
171                view_state,
172                &self.buffer_metadata,
173                &self.working_dir,
174                active_buffer,
175                &self.terminal_buffers,
176                &terminal_indices,
177            );
178            tracing::trace!(
179                "Split {:?}: {} open tabs, active_buffer={:?}",
180                split_id,
181                serialized.open_tabs.len(),
182                active_buffer
183            );
184            split_states.insert(split_id.0, serialized);
185        }
186
187        tracing::debug!(
188            "Captured {} split states, active_split={}",
189            split_states.len(),
190            self.split_manager.active_split().0
191        );
192
193        // Capture file explorer state
194        let file_explorer = if let Some(ref explorer) = self.file_explorer {
195            // Get expanded directories from the tree
196            let expanded_dirs = get_expanded_dirs(explorer, &self.working_dir);
197            FileExplorerState {
198                visible: self.file_explorer_visible,
199                width_percent: self.file_explorer_width_percent,
200                expanded_dirs,
201                scroll_offset: explorer.get_scroll_offset(),
202                show_hidden: explorer.ignore_patterns().show_hidden(),
203                show_gitignored: explorer.ignore_patterns().show_gitignored(),
204            }
205        } else {
206            FileExplorerState {
207                visible: self.file_explorer_visible,
208                width_percent: self.file_explorer_width_percent,
209                expanded_dirs: Vec::new(),
210                scroll_offset: 0,
211                show_hidden: false,
212                show_gitignored: false,
213            }
214        };
215
216        // Capture config overrides (only store deviations from defaults)
217        let config_overrides = SessionConfigOverrides {
218            line_numbers: Some(self.config.editor.line_numbers),
219            relative_line_numbers: Some(self.config.editor.relative_line_numbers),
220            line_wrap: Some(self.config.editor.line_wrap),
221            syntax_highlighting: Some(self.config.editor.syntax_highlighting),
222            enable_inlay_hints: Some(self.config.editor.enable_inlay_hints),
223            mouse_enabled: Some(self.mouse_enabled),
224            menu_bar_hidden: Some(!self.menu_bar_visible),
225        };
226
227        // Capture histories using the items() accessor from the prompt_histories HashMap
228        let histories = SessionHistories {
229            search: self
230                .prompt_histories
231                .get("search")
232                .map(|h| h.items().to_vec())
233                .unwrap_or_default(),
234            replace: self
235                .prompt_histories
236                .get("replace")
237                .map(|h| h.items().to_vec())
238                .unwrap_or_default(),
239            command_palette: Vec::new(), // Future: when command palette has history
240            goto_line: self
241                .prompt_histories
242                .get("goto_line")
243                .map(|h| h.items().to_vec())
244                .unwrap_or_default(),
245            open_file: Vec::new(), // Future: when file open prompt has history
246        };
247        tracing::trace!(
248            "Captured histories: {} search, {} replace",
249            histories.search.len(),
250            histories.replace.len()
251        );
252
253        // Capture search options
254        let search_options = SearchOptions {
255            case_sensitive: self.search_case_sensitive,
256            whole_word: self.search_whole_word,
257            use_regex: self.search_use_regex,
258            confirm_each: self.search_confirm_each,
259        };
260
261        // Capture bookmarks
262        let bookmarks =
263            serialize_bookmarks(&self.bookmarks, &self.buffer_metadata, &self.working_dir);
264
265        // Capture external files (files outside working_dir)
266        // These are stored as absolute paths since they can't be made relative
267        let external_files: Vec<PathBuf> = self
268            .buffer_metadata
269            .values()
270            .filter_map(|meta| meta.file_path())
271            .filter(|abs_path| abs_path.strip_prefix(&self.working_dir).is_err())
272            .cloned()
273            .collect();
274        if !external_files.is_empty() {
275            tracing::debug!("Captured {} external files", external_files.len());
276        }
277
278        Session {
279            version: SESSION_VERSION,
280            working_dir: self.working_dir.clone(),
281            split_layout,
282            active_split_id: self.split_manager.active_split().0,
283            split_states,
284            config_overrides,
285            file_explorer,
286            histories,
287            search_options,
288            bookmarks,
289            terminals,
290            external_files,
291            saved_at: std::time::SystemTime::now()
292                .duration_since(std::time::UNIX_EPOCH)
293                .unwrap_or_default()
294                .as_secs(),
295        }
296    }
297
298    /// Save the current session to disk
299    ///
300    /// Ensures all active terminals have their visible screen synced to
301    /// backing files before capturing the session.
302    /// Also saves global file states (scroll/cursor positions per file).
303    pub fn save_session(&mut self) -> Result<(), SessionError> {
304        // Ensure all terminal backing files have complete state before saving
305        self.sync_all_terminal_backing_files();
306
307        // Save global file states for all open file buffers
308        self.save_all_global_file_states();
309
310        let session = self.capture_session();
311        session.save()
312    }
313
314    /// Save global file states for all open file buffers
315    fn save_all_global_file_states(&self) {
316        // Collect all file states from all splits
317        for (split_id, view_state) in &self.split_view_states {
318            // Get the active buffer for this split
319            let active_buffer = self
320                .split_manager
321                .root()
322                .get_leaves_with_rects(ratatui::layout::Rect::default())
323                .into_iter()
324                .find(|(sid, _, _)| *sid == *split_id)
325                .map(|(_, buffer_id, _)| buffer_id);
326
327            if let Some(buffer_id) = active_buffer {
328                self.save_buffer_file_state(buffer_id, view_state);
329            }
330        }
331    }
332
333    /// Save file state for a specific buffer (used when closing files and saving session)
334    fn save_buffer_file_state(&self, buffer_id: BufferId, view_state: &SplitViewState) {
335        // Get the file path for this buffer
336        let abs_path = match self.buffer_metadata.get(&buffer_id) {
337            Some(metadata) => match metadata.file_path() {
338                Some(path) => path.to_path_buf(),
339                None => return, // Not a file buffer
340            },
341            None => return,
342        };
343
344        // Capture the current state
345        let primary_cursor = view_state.cursors.primary();
346        let file_state = SerializedFileState {
347            cursor: SerializedCursor {
348                position: primary_cursor.position,
349                anchor: primary_cursor.anchor,
350                sticky_column: primary_cursor.sticky_column,
351            },
352            additional_cursors: view_state
353                .cursors
354                .iter()
355                .skip(1)
356                .map(|(_, cursor)| SerializedCursor {
357                    position: cursor.position,
358                    anchor: cursor.anchor,
359                    sticky_column: cursor.sticky_column,
360                })
361                .collect(),
362            scroll: SerializedScroll {
363                top_byte: view_state.viewport.top_byte,
364                top_view_line_offset: view_state.viewport.top_view_line_offset,
365                left_column: view_state.viewport.left_column,
366            },
367        };
368
369        // Save to disk immediately
370        PersistedFileSession::save(&abs_path, file_state);
371    }
372
373    /// Sync all active terminal visible screens to their backing files.
374    ///
375    /// Called before session save to ensure backing files contain complete
376    /// terminal state (scrollback + visible screen).
377    fn sync_all_terminal_backing_files(&mut self) {
378        use std::io::BufWriter;
379
380        // Collect terminal IDs and their backing paths
381        let terminals_to_sync: Vec<_> = self
382            .terminal_buffers
383            .values()
384            .copied()
385            .filter_map(|terminal_id| {
386                self.terminal_backing_files
387                    .get(&terminal_id)
388                    .map(|path| (terminal_id, path.clone()))
389            })
390            .collect();
391
392        for (terminal_id, backing_path) in terminals_to_sync {
393            if let Some(handle) = self.terminal_manager.get(terminal_id) {
394                if let Ok(state) = handle.state.lock() {
395                    // Append visible screen to backing file
396                    if let Ok(mut file) = self.filesystem.open_file_for_append(&backing_path) {
397                        let mut writer = BufWriter::new(&mut *file);
398                        if let Err(e) = state.append_visible_screen(&mut writer) {
399                            tracing::warn!(
400                                "Failed to sync terminal {:?} to backing file: {}",
401                                terminal_id,
402                                e
403                            );
404                        }
405                    }
406                }
407            }
408        }
409    }
410
411    /// Try to load and apply a session for the current working directory
412    ///
413    /// Returns true if a session was successfully loaded and applied.
414    pub fn try_restore_session(&mut self) -> Result<bool, SessionError> {
415        tracing::debug!("Attempting to restore session for {:?}", self.working_dir);
416        match Session::load(&self.working_dir)? {
417            Some(session) => {
418                tracing::info!("Found session, applying...");
419                self.apply_session(&session)?;
420                Ok(true)
421            }
422            None => {
423                tracing::debug!("No session found for {:?}", self.working_dir);
424                Ok(false)
425            }
426        }
427    }
428
429    /// Apply a loaded session to the editor
430    pub fn apply_session(&mut self, session: &Session) -> Result<(), SessionError> {
431        tracing::debug!(
432            "Applying session with {} split states",
433            session.split_states.len()
434        );
435
436        // 1. Apply config overrides
437        if let Some(line_numbers) = session.config_overrides.line_numbers {
438            self.config.editor.line_numbers = line_numbers;
439        }
440        if let Some(relative_line_numbers) = session.config_overrides.relative_line_numbers {
441            self.config.editor.relative_line_numbers = relative_line_numbers;
442        }
443        if let Some(line_wrap) = session.config_overrides.line_wrap {
444            self.config.editor.line_wrap = line_wrap;
445        }
446        if let Some(syntax_highlighting) = session.config_overrides.syntax_highlighting {
447            self.config.editor.syntax_highlighting = syntax_highlighting;
448        }
449        if let Some(enable_inlay_hints) = session.config_overrides.enable_inlay_hints {
450            self.config.editor.enable_inlay_hints = enable_inlay_hints;
451        }
452        if let Some(mouse_enabled) = session.config_overrides.mouse_enabled {
453            self.mouse_enabled = mouse_enabled;
454        }
455        if let Some(menu_bar_hidden) = session.config_overrides.menu_bar_hidden {
456            self.menu_bar_visible = !menu_bar_hidden;
457        }
458
459        // 2. Restore search options
460        self.search_case_sensitive = session.search_options.case_sensitive;
461        self.search_whole_word = session.search_options.whole_word;
462        self.search_use_regex = session.search_options.use_regex;
463        self.search_confirm_each = session.search_options.confirm_each;
464
465        // 3. Restore histories (merge with any existing)
466        tracing::debug!(
467            "Restoring histories: {} search, {} replace, {} goto_line",
468            session.histories.search.len(),
469            session.histories.replace.len(),
470            session.histories.goto_line.len()
471        );
472        for item in &session.histories.search {
473            self.get_or_create_prompt_history("search")
474                .push(item.clone());
475        }
476        for item in &session.histories.replace {
477            self.get_or_create_prompt_history("replace")
478                .push(item.clone());
479        }
480        for item in &session.histories.goto_line {
481            self.get_or_create_prompt_history("goto_line")
482                .push(item.clone());
483        }
484
485        // 4. Restore file explorer state
486        self.file_explorer_visible = session.file_explorer.visible;
487        self.file_explorer_width_percent = session.file_explorer.width_percent;
488
489        // Store pending show_hidden and show_gitignored settings (fixes #569)
490        // These will be applied when the file explorer is initialized (async)
491        if session.file_explorer.show_hidden {
492            self.pending_file_explorer_show_hidden = Some(true);
493        }
494        if session.file_explorer.show_gitignored {
495            self.pending_file_explorer_show_gitignored = Some(true);
496        }
497
498        // Initialize file explorer if it was visible in the session
499        // Note: We keep key_context as Normal so the editor has focus, not the explorer
500        if self.file_explorer_visible && self.file_explorer.is_none() {
501            self.init_file_explorer();
502        }
503
504        // 5. Open files from the session and build buffer mappings
505        // Collect all unique file paths from split_states (which tracks all open files per split)
506        let file_paths = collect_file_paths_from_states(&session.split_states);
507        tracing::debug!(
508            "Session has {} files to restore: {:?}",
509            file_paths.len(),
510            file_paths
511        );
512        let mut path_to_buffer: HashMap<PathBuf, BufferId> = HashMap::new();
513
514        for rel_path in file_paths {
515            let abs_path = self.working_dir.join(&rel_path);
516            tracing::trace!(
517                "Checking file: {:?} (exists: {})",
518                abs_path,
519                abs_path.exists()
520            );
521            if abs_path.exists() {
522                // Open the file (this will reuse existing buffer if already open)
523                match self.open_file_internal(&abs_path) {
524                    Ok(buffer_id) => {
525                        tracing::debug!("Opened file {:?} as buffer {:?}", rel_path, buffer_id);
526                        path_to_buffer.insert(rel_path, buffer_id);
527                    }
528                    Err(e) => {
529                        tracing::warn!("Failed to open file {:?}: {}", abs_path, e);
530                    }
531                }
532            } else {
533                tracing::debug!("Skipping non-existent file: {:?}", abs_path);
534            }
535        }
536
537        tracing::debug!("Opened {} files from session", path_to_buffer.len());
538
539        // 5b. Restore external files (files outside the working directory)
540        // These are stored as absolute paths
541        if !session.external_files.is_empty() {
542            tracing::debug!(
543                "Restoring {} external files: {:?}",
544                session.external_files.len(),
545                session.external_files
546            );
547            for abs_path in &session.external_files {
548                if abs_path.exists() {
549                    match self.open_file_internal(abs_path) {
550                        Ok(buffer_id) => {
551                            tracing::debug!(
552                                "Restored external file {:?} as buffer {:?}",
553                                abs_path,
554                                buffer_id
555                            );
556                        }
557                        Err(e) => {
558                            tracing::warn!("Failed to restore external file {:?}: {}", abs_path, e);
559                        }
560                    }
561                } else {
562                    tracing::debug!("Skipping non-existent external file: {:?}", abs_path);
563                }
564            }
565        }
566
567        // Restore terminals and build index -> buffer map
568        let mut terminal_buffer_map: HashMap<usize, BufferId> = HashMap::new();
569        if !session.terminals.is_empty() {
570            if let Some(ref bridge) = self.async_bridge {
571                self.terminal_manager.set_async_bridge(bridge.clone());
572            }
573            for terminal in &session.terminals {
574                if let Some(buffer_id) = self.restore_terminal_from_session(terminal) {
575                    terminal_buffer_map.insert(terminal.terminal_index, buffer_id);
576                }
577            }
578        }
579
580        // 6. Rebuild split layout from the saved tree
581        // Map old split IDs to new ones as we create splits
582        let mut split_id_map: HashMap<usize, SplitId> = HashMap::new();
583        self.restore_split_node(
584            &session.split_layout,
585            &path_to_buffer,
586            &terminal_buffer_map,
587            &session.split_states,
588            &mut split_id_map,
589            true, // is_first_leaf - the first leaf reuses the existing split
590        );
591
592        // Set the active split based on the saved active_split_id
593        // NOTE: active_buffer is now derived from split_manager, which was already
594        // correctly set up by restore_split_view_state() via set_split_buffer()
595        if let Some(&new_active_split) = split_id_map.get(&session.active_split_id) {
596            self.split_manager.set_active_split(new_active_split);
597        }
598
599        // 7. Restore bookmarks
600        for (key, bookmark) in &session.bookmarks {
601            if let Some(&buffer_id) = path_to_buffer.get(&bookmark.file_path) {
602                // Verify position is valid
603                if let Some(buffer) = self.buffers.get(&buffer_id) {
604                    let pos = bookmark.position.min(buffer.buffer.len());
605                    self.bookmarks.insert(
606                        *key,
607                        Bookmark {
608                            buffer_id,
609                            position: pos,
610                        },
611                    );
612                }
613            }
614        }
615
616        tracing::debug!(
617            "Session restore complete: {} splits, {} buffers",
618            self.split_view_states.len(),
619            self.buffers.len()
620        );
621
622        Ok(())
623    }
624
625    /// Restore a terminal from serialized session metadata.
626    ///
627    /// Uses the incremental streaming architecture for fast restore:
628    /// 1. Load backing file directly as read-only buffer (lazy load)
629    /// 2. Skip log replay entirely - user sees last session state immediately
630    /// 3. Spawn new PTY for live terminal when user re-enters terminal mode
631    ///
632    /// Performance: O(1) for restore vs O(total_history) with log replay
633    fn restore_terminal_from_session(
634        &mut self,
635        terminal: &SerializedTerminalSession,
636    ) -> Option<BufferId> {
637        // Resolve paths (accept absolute; otherwise treat as relative to terminals dir)
638        let terminals_root = self.dir_context.terminal_dir_for(&self.working_dir);
639        let log_path = if terminal.log_path.is_absolute() {
640            terminal.log_path.clone()
641        } else {
642            terminals_root.join(&terminal.log_path)
643        };
644        let backing_path = if terminal.backing_path.is_absolute() {
645            terminal.backing_path.clone()
646        } else {
647            terminals_root.join(&terminal.backing_path)
648        };
649
650        let _ = self.filesystem.create_dir_all(
651            log_path
652                .parent()
653                .or_else(|| backing_path.parent())
654                .unwrap_or(&terminals_root),
655        );
656
657        // Record paths using the predicted ID so buffer creation can reuse them
658        let predicted_id = self.terminal_manager.next_terminal_id();
659        self.terminal_log_files
660            .insert(predicted_id, log_path.clone());
661        self.terminal_backing_files
662            .insert(predicted_id, backing_path.clone());
663
664        // Spawn the terminal with backing file for incremental scrollback
665        let terminal_id = match self.terminal_manager.spawn(
666            terminal.cols,
667            terminal.rows,
668            terminal.cwd.clone(),
669            Some(log_path.clone()),
670            Some(backing_path.clone()),
671        ) {
672            Ok(id) => id,
673            Err(e) => {
674                tracing::warn!(
675                    "Failed to restore terminal {}: {}",
676                    terminal.terminal_index,
677                    e
678                );
679                return None;
680            }
681        };
682
683        // Ensure maps keyed by actual ID
684        if terminal_id != predicted_id {
685            self.terminal_log_files
686                .insert(terminal_id, log_path.clone());
687            self.terminal_backing_files
688                .insert(terminal_id, backing_path.clone());
689            self.terminal_log_files.remove(&predicted_id);
690            self.terminal_backing_files.remove(&predicted_id);
691        }
692
693        // Create buffer for this terminal
694        let buffer_id = self.create_terminal_buffer_detached(terminal_id);
695
696        // Load backing file directly as read-only buffer (skip log replay)
697        // The backing file already contains complete terminal state from last session
698        self.load_terminal_backing_file_as_buffer(buffer_id, &backing_path);
699
700        Some(buffer_id)
701    }
702
703    /// Load a terminal backing file directly as a read-only buffer.
704    ///
705    /// This is used for fast session restore - we load the pre-rendered backing
706    /// file instead of replaying the raw log through the VTE parser.
707    fn load_terminal_backing_file_as_buffer(&mut self, buffer_id: BufferId, backing_path: &Path) {
708        // Check if backing file exists; if not, terminal starts empty
709        if !backing_path.exists() {
710            return;
711        }
712
713        let large_file_threshold = self.config.editor.large_file_threshold_bytes as usize;
714        if let Ok(new_state) = EditorState::from_file_with_languages(
715            backing_path,
716            self.terminal_width,
717            self.terminal_height,
718            large_file_threshold,
719            &self.grammar_registry,
720            &self.config.languages,
721            std::sync::Arc::clone(&self.filesystem),
722        ) {
723            if let Some(state) = self.buffers.get_mut(&buffer_id) {
724                *state = new_state;
725                // Move cursor to end of buffer
726                let total = state.buffer.total_bytes();
727                state.primary_cursor_mut().position = total;
728                // Terminal buffers should never be considered "modified"
729                state.buffer.set_modified(false);
730                // Start in scrollback mode (editing disabled)
731                state.editing_disabled = true;
732                state.margins.set_line_numbers(false);
733            }
734        }
735    }
736
737    /// Internal helper to open a file and return its buffer ID
738    fn open_file_internal(&mut self, path: &Path) -> Result<BufferId, SessionError> {
739        // Check if file is already open
740        for (buffer_id, metadata) in &self.buffer_metadata {
741            if let Some(file_path) = metadata.file_path() {
742                if file_path == path {
743                    return Ok(*buffer_id);
744                }
745            }
746        }
747
748        // File not open, open it using the Editor's open_file method
749        self.open_file(path).map_err(SessionError::Io)
750    }
751
752    /// Recursively restore the split layout from a serialized tree
753    fn restore_split_node(
754        &mut self,
755        node: &SerializedSplitNode,
756        path_to_buffer: &HashMap<PathBuf, BufferId>,
757        terminal_buffers: &HashMap<usize, BufferId>,
758        split_states: &HashMap<usize, SerializedSplitViewState>,
759        split_id_map: &mut HashMap<usize, SplitId>,
760        is_first_leaf: bool,
761    ) {
762        match node {
763            SerializedSplitNode::Leaf {
764                file_path,
765                split_id,
766            } => {
767                // Get the buffer for this file, or use the default buffer
768                let buffer_id = file_path
769                    .as_ref()
770                    .and_then(|p| path_to_buffer.get(p).copied())
771                    .unwrap_or(self.active_buffer());
772
773                let current_split_id = if is_first_leaf {
774                    // First leaf reuses the existing split
775                    let split_id_val = self.split_manager.active_split();
776                    let _ = self.split_manager.set_split_buffer(split_id_val, buffer_id);
777                    split_id_val
778                } else {
779                    // Non-first leaves use the active split (created by split_active)
780                    self.split_manager.active_split()
781                };
782
783                // Map old split ID to new one
784                split_id_map.insert(*split_id, current_split_id);
785
786                // Restore the view state for this split
787                self.restore_split_view_state(
788                    current_split_id,
789                    *split_id,
790                    split_states,
791                    path_to_buffer,
792                    terminal_buffers,
793                );
794            }
795            SerializedSplitNode::Terminal {
796                terminal_index,
797                split_id,
798            } => {
799                let buffer_id = terminal_buffers
800                    .get(terminal_index)
801                    .copied()
802                    .unwrap_or(self.active_buffer());
803
804                let current_split_id = if is_first_leaf {
805                    let split_id_val = self.split_manager.active_split();
806                    let _ = self.split_manager.set_split_buffer(split_id_val, buffer_id);
807                    split_id_val
808                } else {
809                    self.split_manager.active_split()
810                };
811
812                split_id_map.insert(*split_id, current_split_id);
813
814                let _ = self
815                    .split_manager
816                    .set_split_buffer(current_split_id, buffer_id);
817
818                self.restore_split_view_state(
819                    current_split_id,
820                    *split_id,
821                    split_states,
822                    path_to_buffer,
823                    terminal_buffers,
824                );
825            }
826            SerializedSplitNode::Split {
827                direction,
828                first,
829                second,
830                ratio,
831                split_id,
832            } => {
833                // First, restore the first child (it uses the current active split)
834                self.restore_split_node(
835                    first,
836                    path_to_buffer,
837                    terminal_buffers,
838                    split_states,
839                    split_id_map,
840                    is_first_leaf,
841                );
842
843                // Get the buffer for the second child's first leaf
844                let second_buffer_id =
845                    get_first_leaf_buffer(second, path_to_buffer, terminal_buffers)
846                        .unwrap_or(self.active_buffer());
847
848                // Convert direction
849                let split_direction = match direction {
850                    SerializedSplitDirection::Horizontal => SplitDirection::Horizontal,
851                    SerializedSplitDirection::Vertical => SplitDirection::Vertical,
852                };
853
854                // Create the split for the second child
855                match self
856                    .split_manager
857                    .split_active(split_direction, second_buffer_id, *ratio)
858                {
859                    Ok(new_split_id) => {
860                        // Create view state for the new split
861                        let mut view_state = SplitViewState::with_buffer(
862                            self.terminal_width,
863                            self.terminal_height,
864                            second_buffer_id,
865                        );
866                        view_state.viewport.line_wrap_enabled = self.config.editor.line_wrap;
867                        self.split_view_states.insert(new_split_id, view_state);
868
869                        // Map the container split ID (though we mainly care about leaves)
870                        split_id_map.insert(*split_id, new_split_id);
871
872                        // Recursively restore the second child (it's now in the new split)
873                        self.restore_split_node(
874                            second,
875                            path_to_buffer,
876                            terminal_buffers,
877                            split_states,
878                            split_id_map,
879                            false,
880                        );
881                    }
882                    Err(e) => {
883                        tracing::error!("Failed to create split during session restore: {}", e);
884                    }
885                }
886            }
887        }
888    }
889
890    /// Restore view state for a specific split
891    fn restore_split_view_state(
892        &mut self,
893        current_split_id: SplitId,
894        saved_split_id: usize,
895        split_states: &HashMap<usize, SerializedSplitViewState>,
896        path_to_buffer: &HashMap<PathBuf, BufferId>,
897        terminal_buffers: &HashMap<usize, BufferId>,
898    ) {
899        // Try to find the saved state for this split
900        let Some(split_state) = split_states.get(&saved_split_id) else {
901            return;
902        };
903
904        let Some(view_state) = self.split_view_states.get_mut(&current_split_id) else {
905            return;
906        };
907
908        let mut active_buffer_id: Option<BufferId> = None;
909
910        if !split_state.open_tabs.is_empty() {
911            for tab in &split_state.open_tabs {
912                match tab {
913                    SerializedTabRef::File(rel_path) => {
914                        if let Some(&buffer_id) = path_to_buffer.get(rel_path) {
915                            if !view_state.open_buffers.contains(&buffer_id) {
916                                view_state.open_buffers.push(buffer_id);
917                            }
918                            if terminal_buffers.values().any(|&tid| tid == buffer_id) {
919                                view_state.viewport.line_wrap_enabled = false;
920                            }
921                        }
922                    }
923                    SerializedTabRef::Terminal(index) => {
924                        if let Some(&buffer_id) = terminal_buffers.get(index) {
925                            if !view_state.open_buffers.contains(&buffer_id) {
926                                view_state.open_buffers.push(buffer_id);
927                            }
928                            view_state.viewport.line_wrap_enabled = false;
929                        }
930                    }
931                }
932            }
933
934            if let Some(active_idx) = split_state.active_tab_index {
935                if let Some(tab) = split_state.open_tabs.get(active_idx) {
936                    active_buffer_id = match tab {
937                        SerializedTabRef::File(rel) => path_to_buffer.get(rel).copied(),
938                        SerializedTabRef::Terminal(index) => terminal_buffers.get(index).copied(),
939                    };
940                }
941            }
942        } else {
943            // Backward compatibility path using open_files/active_file_index
944            for rel_path in &split_state.open_files {
945                if let Some(&buffer_id) = path_to_buffer.get(rel_path) {
946                    if !view_state.open_buffers.contains(&buffer_id) {
947                        view_state.open_buffers.push(buffer_id);
948                    }
949                }
950            }
951
952            let active_file_path = split_state.open_files.get(split_state.active_file_index);
953            active_buffer_id =
954                active_file_path.and_then(|rel_path| path_to_buffer.get(rel_path).copied());
955        }
956
957        // Restore cursor and scroll for the active file
958        if let Some(active_id) = active_buffer_id {
959            // Find the file state for the active buffer
960            for (rel_path, file_state) in &split_state.file_states {
961                let buffer_for_path = path_to_buffer.get(rel_path).copied();
962                if buffer_for_path == Some(active_id) {
963                    if let Some(buffer) = self.buffers.get(&active_id) {
964                        let max_pos = buffer.buffer.len();
965                        let cursor_pos = file_state.cursor.position.min(max_pos);
966
967                        // Set cursor in SplitViewState
968                        view_state.cursors.primary_mut().position = cursor_pos;
969                        view_state.cursors.primary_mut().anchor =
970                            file_state.cursor.anchor.map(|a| a.min(max_pos));
971                        view_state.cursors.primary_mut().sticky_column =
972                            file_state.cursor.sticky_column;
973
974                        // Set scroll position
975                        view_state.viewport.top_byte = file_state.scroll.top_byte.min(max_pos);
976                        view_state.viewport.top_view_line_offset =
977                            file_state.scroll.top_view_line_offset;
978                        view_state.viewport.left_column = file_state.scroll.left_column;
979                        // Mark viewport to skip sync on first resize after session restore
980                        // This prevents ensure_visible from overwriting the restored scroll position
981                        view_state.viewport.set_skip_resize_sync();
982
983                        tracing::trace!(
984                            "Restored SplitViewState for {:?}: cursor={}, top_byte={}",
985                            rel_path,
986                            cursor_pos,
987                            view_state.viewport.top_byte
988                        );
989                    }
990
991                    // Also set cursor in EditorState (authoritative for cursors)
992                    if let Some(editor_state) = self.buffers.get_mut(&active_id) {
993                        let max_pos = editor_state.buffer.len();
994                        let cursor_pos = file_state.cursor.position.min(max_pos);
995                        editor_state.cursors.primary_mut().position = cursor_pos;
996                        editor_state.cursors.primary_mut().anchor =
997                            file_state.cursor.anchor.map(|a| a.min(max_pos));
998                        editor_state.cursors.primary_mut().sticky_column =
999                            file_state.cursor.sticky_column;
1000                        // Note: viewport is now exclusively owned by SplitViewState (restored above)
1001                    }
1002                    break;
1003                }
1004            }
1005
1006            // Set this buffer as active in the split
1007            let _ = self
1008                .split_manager
1009                .set_split_buffer(current_split_id, active_id);
1010        }
1011
1012        // Restore view mode
1013        view_state.view_mode = match split_state.view_mode {
1014            SerializedViewMode::Source => ViewMode::Source,
1015            SerializedViewMode::Compose => ViewMode::Compose,
1016        };
1017        view_state.compose_width = split_state.compose_width;
1018        view_state.tab_scroll_offset = split_state.tab_scroll_offset;
1019    }
1020}
1021
1022/// Helper: Get the buffer ID from the first leaf node in a split tree
1023fn get_first_leaf_buffer(
1024    node: &SerializedSplitNode,
1025    path_to_buffer: &HashMap<PathBuf, BufferId>,
1026    terminal_buffers: &HashMap<usize, BufferId>,
1027) -> Option<BufferId> {
1028    match node {
1029        SerializedSplitNode::Leaf { file_path, .. } => file_path
1030            .as_ref()
1031            .and_then(|p| path_to_buffer.get(p).copied()),
1032        SerializedSplitNode::Terminal { terminal_index, .. } => {
1033            terminal_buffers.get(terminal_index).copied()
1034        }
1035        SerializedSplitNode::Split { first, .. } => {
1036            get_first_leaf_buffer(first, path_to_buffer, terminal_buffers)
1037        }
1038    }
1039}
1040
1041// ============================================================================
1042// Serialization helpers
1043// ============================================================================
1044
1045fn serialize_split_node(
1046    node: &SplitNode,
1047    buffer_metadata: &HashMap<BufferId, super::types::BufferMetadata>,
1048    working_dir: &Path,
1049    terminal_buffers: &HashMap<BufferId, TerminalId>,
1050    terminal_indices: &HashMap<TerminalId, usize>,
1051) -> SerializedSplitNode {
1052    match node {
1053        SplitNode::Leaf {
1054            buffer_id,
1055            split_id,
1056        } => {
1057            if let Some(terminal_id) = terminal_buffers.get(buffer_id) {
1058                if let Some(index) = terminal_indices.get(terminal_id) {
1059                    return SerializedSplitNode::Terminal {
1060                        terminal_index: *index,
1061                        split_id: split_id.0,
1062                    };
1063                }
1064            }
1065
1066            let file_path = buffer_metadata
1067                .get(buffer_id)
1068                .and_then(|meta| meta.file_path())
1069                .and_then(|abs_path| {
1070                    abs_path
1071                        .strip_prefix(working_dir)
1072                        .ok()
1073                        .map(|p| p.to_path_buf())
1074                });
1075
1076            SerializedSplitNode::Leaf {
1077                file_path,
1078                split_id: split_id.0,
1079            }
1080        }
1081        SplitNode::Split {
1082            direction,
1083            first,
1084            second,
1085            ratio,
1086            split_id,
1087        } => SerializedSplitNode::Split {
1088            direction: match direction {
1089                SplitDirection::Horizontal => SerializedSplitDirection::Horizontal,
1090                SplitDirection::Vertical => SerializedSplitDirection::Vertical,
1091            },
1092            first: Box::new(serialize_split_node(
1093                first,
1094                buffer_metadata,
1095                working_dir,
1096                terminal_buffers,
1097                terminal_indices,
1098            )),
1099            second: Box::new(serialize_split_node(
1100                second,
1101                buffer_metadata,
1102                working_dir,
1103                terminal_buffers,
1104                terminal_indices,
1105            )),
1106            ratio: *ratio,
1107            split_id: split_id.0,
1108        },
1109    }
1110}
1111
1112fn serialize_split_view_state(
1113    view_state: &crate::view::split::SplitViewState,
1114    buffer_metadata: &HashMap<BufferId, super::types::BufferMetadata>,
1115    working_dir: &Path,
1116    active_buffer: Option<BufferId>,
1117    terminal_buffers: &HashMap<BufferId, TerminalId>,
1118    terminal_indices: &HashMap<TerminalId, usize>,
1119) -> SerializedSplitViewState {
1120    let mut open_tabs = Vec::new();
1121    let mut open_files = Vec::new();
1122    let mut active_tab_index = None;
1123
1124    for buffer_id in &view_state.open_buffers {
1125        let tab_index = open_tabs.len();
1126        if let Some(terminal_id) = terminal_buffers.get(buffer_id) {
1127            if let Some(idx) = terminal_indices.get(terminal_id) {
1128                open_tabs.push(SerializedTabRef::Terminal(*idx));
1129                if Some(*buffer_id) == active_buffer {
1130                    active_tab_index = Some(tab_index);
1131                }
1132                continue;
1133            }
1134        }
1135
1136        if let Some(rel_path) = buffer_metadata
1137            .get(buffer_id)
1138            .and_then(|meta| meta.file_path())
1139            .and_then(|abs_path| abs_path.strip_prefix(working_dir).ok())
1140        {
1141            open_tabs.push(SerializedTabRef::File(rel_path.to_path_buf()));
1142            open_files.push(rel_path.to_path_buf());
1143            if Some(*buffer_id) == active_buffer {
1144                active_tab_index = Some(tab_index);
1145            }
1146        }
1147    }
1148
1149    // Derive active_file_index for backward compatibility
1150    let active_file_index = active_tab_index
1151        .and_then(|idx| open_tabs.get(idx))
1152        .and_then(|tab| match tab {
1153            SerializedTabRef::File(path) => {
1154                Some(open_files.iter().position(|p| p == path).unwrap_or(0))
1155            }
1156            _ => None,
1157        })
1158        .unwrap_or(0);
1159
1160    // Serialize file states - only save cursor/scroll for the ACTIVE buffer if it is a file
1161    let mut file_states = HashMap::new();
1162    if let Some(active_id) = active_buffer {
1163        if let Some(meta) = buffer_metadata.get(&active_id) {
1164            if let Some(abs_path) = meta.file_path() {
1165                if let Ok(rel_path) = abs_path.strip_prefix(working_dir) {
1166                    let primary_cursor = view_state.cursors.primary();
1167
1168                    file_states.insert(
1169                        rel_path.to_path_buf(),
1170                        SerializedFileState {
1171                            cursor: SerializedCursor {
1172                                position: primary_cursor.position,
1173                                anchor: primary_cursor.anchor,
1174                                sticky_column: primary_cursor.sticky_column,
1175                            },
1176                            additional_cursors: view_state
1177                                .cursors
1178                                .iter()
1179                                .skip(1) // Skip primary
1180                                .map(|(_, cursor)| SerializedCursor {
1181                                    position: cursor.position,
1182                                    anchor: cursor.anchor,
1183                                    sticky_column: cursor.sticky_column,
1184                                })
1185                                .collect(),
1186                            scroll: SerializedScroll {
1187                                top_byte: view_state.viewport.top_byte,
1188                                top_view_line_offset: view_state.viewport.top_view_line_offset,
1189                                left_column: view_state.viewport.left_column,
1190                            },
1191                        },
1192                    );
1193                }
1194            }
1195        }
1196    }
1197
1198    SerializedSplitViewState {
1199        open_tabs,
1200        active_tab_index,
1201        open_files,
1202        active_file_index,
1203        file_states,
1204        tab_scroll_offset: view_state.tab_scroll_offset,
1205        view_mode: match view_state.view_mode {
1206            ViewMode::Source => SerializedViewMode::Source,
1207            ViewMode::Compose => SerializedViewMode::Compose,
1208        },
1209        compose_width: view_state.compose_width,
1210    }
1211}
1212
1213fn serialize_bookmarks(
1214    bookmarks: &HashMap<char, Bookmark>,
1215    buffer_metadata: &HashMap<BufferId, super::types::BufferMetadata>,
1216    working_dir: &Path,
1217) -> HashMap<char, SerializedBookmark> {
1218    bookmarks
1219        .iter()
1220        .filter_map(|(key, bookmark)| {
1221            buffer_metadata
1222                .get(&bookmark.buffer_id)
1223                .and_then(|meta| meta.file_path())
1224                .and_then(|abs_path| {
1225                    abs_path.strip_prefix(working_dir).ok().map(|rel_path| {
1226                        (
1227                            *key,
1228                            SerializedBookmark {
1229                                file_path: rel_path.to_path_buf(),
1230                                position: bookmark.position,
1231                            },
1232                        )
1233                    })
1234                })
1235        })
1236        .collect()
1237}
1238
1239/// Collect all unique file paths from split_states
1240fn collect_file_paths_from_states(
1241    split_states: &HashMap<usize, SerializedSplitViewState>,
1242) -> Vec<PathBuf> {
1243    let mut paths = Vec::new();
1244    for state in split_states.values() {
1245        if !state.open_tabs.is_empty() {
1246            for tab in &state.open_tabs {
1247                if let SerializedTabRef::File(path) = tab {
1248                    if !paths.contains(path) {
1249                        paths.push(path.clone());
1250                    }
1251                }
1252            }
1253        } else {
1254            for path in &state.open_files {
1255                if !paths.contains(path) {
1256                    paths.push(path.clone());
1257                }
1258            }
1259        }
1260    }
1261    paths
1262}
1263
1264/// Get list of expanded directories from a FileTreeView
1265fn get_expanded_dirs(
1266    explorer: &crate::view::file_tree::FileTreeView,
1267    working_dir: &Path,
1268) -> Vec<PathBuf> {
1269    let mut expanded = Vec::new();
1270    let tree = explorer.tree();
1271
1272    // Iterate through all nodes and collect expanded directories
1273    for node in tree.all_nodes() {
1274        if node.is_expanded() && node.is_dir() {
1275            // Get the path and make it relative to working_dir
1276            if let Ok(rel_path) = node.entry.path.strip_prefix(working_dir) {
1277                expanded.push(rel_path.to_path_buf());
1278            }
1279        }
1280    }
1281
1282    expanded
1283}