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) = std::fs::OpenOptions::new()
397                        .create(true)
398                        .append(true)
399                        .open(&backing_path)
400                    {
401                        let mut writer = BufWriter::new(&mut file);
402                        if let Err(e) = state.append_visible_screen(&mut writer) {
403                            tracing::warn!(
404                                "Failed to sync terminal {:?} to backing file: {}",
405                                terminal_id,
406                                e
407                            );
408                        }
409                    }
410                }
411            }
412        }
413    }
414
415    /// Try to load and apply a session for the current working directory
416    ///
417    /// Returns true if a session was successfully loaded and applied.
418    pub fn try_restore_session(&mut self) -> Result<bool, SessionError> {
419        tracing::debug!("Attempting to restore session for {:?}", self.working_dir);
420        match Session::load(&self.working_dir)? {
421            Some(session) => {
422                tracing::info!("Found session, applying...");
423                self.apply_session(&session)?;
424                Ok(true)
425            }
426            None => {
427                tracing::debug!("No session found for {:?}", self.working_dir);
428                Ok(false)
429            }
430        }
431    }
432
433    /// Apply a loaded session to the editor
434    pub fn apply_session(&mut self, session: &Session) -> Result<(), SessionError> {
435        tracing::debug!(
436            "Applying session with {} split states",
437            session.split_states.len()
438        );
439
440        // 1. Apply config overrides
441        if let Some(line_numbers) = session.config_overrides.line_numbers {
442            self.config.editor.line_numbers = line_numbers;
443        }
444        if let Some(relative_line_numbers) = session.config_overrides.relative_line_numbers {
445            self.config.editor.relative_line_numbers = relative_line_numbers;
446        }
447        if let Some(line_wrap) = session.config_overrides.line_wrap {
448            self.config.editor.line_wrap = line_wrap;
449        }
450        if let Some(syntax_highlighting) = session.config_overrides.syntax_highlighting {
451            self.config.editor.syntax_highlighting = syntax_highlighting;
452        }
453        if let Some(enable_inlay_hints) = session.config_overrides.enable_inlay_hints {
454            self.config.editor.enable_inlay_hints = enable_inlay_hints;
455        }
456        if let Some(mouse_enabled) = session.config_overrides.mouse_enabled {
457            self.mouse_enabled = mouse_enabled;
458        }
459        if let Some(menu_bar_hidden) = session.config_overrides.menu_bar_hidden {
460            self.menu_bar_visible = !menu_bar_hidden;
461        }
462
463        // 2. Restore search options
464        self.search_case_sensitive = session.search_options.case_sensitive;
465        self.search_whole_word = session.search_options.whole_word;
466        self.search_use_regex = session.search_options.use_regex;
467        self.search_confirm_each = session.search_options.confirm_each;
468
469        // 3. Restore histories (merge with any existing)
470        tracing::debug!(
471            "Restoring histories: {} search, {} replace, {} goto_line",
472            session.histories.search.len(),
473            session.histories.replace.len(),
474            session.histories.goto_line.len()
475        );
476        for item in &session.histories.search {
477            self.get_or_create_prompt_history("search")
478                .push(item.clone());
479        }
480        for item in &session.histories.replace {
481            self.get_or_create_prompt_history("replace")
482                .push(item.clone());
483        }
484        for item in &session.histories.goto_line {
485            self.get_or_create_prompt_history("goto_line")
486                .push(item.clone());
487        }
488
489        // 4. Restore file explorer state
490        self.file_explorer_visible = session.file_explorer.visible;
491        self.file_explorer_width_percent = session.file_explorer.width_percent;
492
493        // Store pending show_hidden and show_gitignored settings (fixes #569)
494        // These will be applied when the file explorer is initialized (async)
495        if session.file_explorer.show_hidden {
496            self.pending_file_explorer_show_hidden = Some(true);
497        }
498        if session.file_explorer.show_gitignored {
499            self.pending_file_explorer_show_gitignored = Some(true);
500        }
501
502        // Initialize file explorer if it was visible in the session
503        // Note: We keep key_context as Normal so the editor has focus, not the explorer
504        if self.file_explorer_visible && self.file_explorer.is_none() {
505            self.init_file_explorer();
506        }
507
508        // 5. Open files from the session and build buffer mappings
509        // Collect all unique file paths from split_states (which tracks all open files per split)
510        let file_paths = collect_file_paths_from_states(&session.split_states);
511        tracing::debug!(
512            "Session has {} files to restore: {:?}",
513            file_paths.len(),
514            file_paths
515        );
516        let mut path_to_buffer: HashMap<PathBuf, BufferId> = HashMap::new();
517
518        for rel_path in file_paths {
519            let abs_path = self.working_dir.join(&rel_path);
520            tracing::trace!(
521                "Checking file: {:?} (exists: {})",
522                abs_path,
523                abs_path.exists()
524            );
525            if abs_path.exists() {
526                // Open the file (this will reuse existing buffer if already open)
527                match self.open_file_internal(&abs_path) {
528                    Ok(buffer_id) => {
529                        tracing::debug!("Opened file {:?} as buffer {:?}", rel_path, buffer_id);
530                        path_to_buffer.insert(rel_path, buffer_id);
531                    }
532                    Err(e) => {
533                        tracing::warn!("Failed to open file {:?}: {}", abs_path, e);
534                    }
535                }
536            } else {
537                tracing::debug!("Skipping non-existent file: {:?}", abs_path);
538            }
539        }
540
541        tracing::debug!("Opened {} files from session", path_to_buffer.len());
542
543        // 5b. Restore external files (files outside the working directory)
544        // These are stored as absolute paths
545        if !session.external_files.is_empty() {
546            tracing::debug!(
547                "Restoring {} external files: {:?}",
548                session.external_files.len(),
549                session.external_files
550            );
551            for abs_path in &session.external_files {
552                if abs_path.exists() {
553                    match self.open_file_internal(abs_path) {
554                        Ok(buffer_id) => {
555                            tracing::debug!(
556                                "Restored external file {:?} as buffer {:?}",
557                                abs_path,
558                                buffer_id
559                            );
560                        }
561                        Err(e) => {
562                            tracing::warn!("Failed to restore external file {:?}: {}", abs_path, e);
563                        }
564                    }
565                } else {
566                    tracing::debug!("Skipping non-existent external file: {:?}", abs_path);
567                }
568            }
569        }
570
571        // Restore terminals and build index -> buffer map
572        let mut terminal_buffer_map: HashMap<usize, BufferId> = HashMap::new();
573        if !session.terminals.is_empty() {
574            if let Some(ref bridge) = self.async_bridge {
575                self.terminal_manager.set_async_bridge(bridge.clone());
576            }
577            for terminal in &session.terminals {
578                if let Some(buffer_id) = self.restore_terminal_from_session(terminal) {
579                    terminal_buffer_map.insert(terminal.terminal_index, buffer_id);
580                }
581            }
582        }
583
584        // 6. Rebuild split layout from the saved tree
585        // Map old split IDs to new ones as we create splits
586        let mut split_id_map: HashMap<usize, SplitId> = HashMap::new();
587        self.restore_split_node(
588            &session.split_layout,
589            &path_to_buffer,
590            &terminal_buffer_map,
591            &session.split_states,
592            &mut split_id_map,
593            true, // is_first_leaf - the first leaf reuses the existing split
594        );
595
596        // Set the active split based on the saved active_split_id
597        // NOTE: active_buffer is now derived from split_manager, which was already
598        // correctly set up by restore_split_view_state() via set_split_buffer()
599        if let Some(&new_active_split) = split_id_map.get(&session.active_split_id) {
600            self.split_manager.set_active_split(new_active_split);
601        }
602
603        // 7. Restore bookmarks
604        for (key, bookmark) in &session.bookmarks {
605            if let Some(&buffer_id) = path_to_buffer.get(&bookmark.file_path) {
606                // Verify position is valid
607                if let Some(buffer) = self.buffers.get(&buffer_id) {
608                    let pos = bookmark.position.min(buffer.buffer.len());
609                    self.bookmarks.insert(
610                        *key,
611                        Bookmark {
612                            buffer_id,
613                            position: pos,
614                        },
615                    );
616                }
617            }
618        }
619
620        tracing::debug!(
621            "Session restore complete: {} splits, {} buffers",
622            self.split_view_states.len(),
623            self.buffers.len()
624        );
625
626        Ok(())
627    }
628
629    /// Restore a terminal from serialized session metadata.
630    ///
631    /// Uses the incremental streaming architecture for fast restore:
632    /// 1. Load backing file directly as read-only buffer (lazy load)
633    /// 2. Skip log replay entirely - user sees last session state immediately
634    /// 3. Spawn new PTY for live terminal when user re-enters terminal mode
635    ///
636    /// Performance: O(1) for restore vs O(total_history) with log replay
637    fn restore_terminal_from_session(
638        &mut self,
639        terminal: &SerializedTerminalSession,
640    ) -> Option<BufferId> {
641        // Resolve paths (accept absolute; otherwise treat as relative to terminals dir)
642        let terminals_root = self.dir_context.terminal_dir_for(&self.working_dir);
643        let log_path = if terminal.log_path.is_absolute() {
644            terminal.log_path.clone()
645        } else {
646            terminals_root.join(&terminal.log_path)
647        };
648        let backing_path = if terminal.backing_path.is_absolute() {
649            terminal.backing_path.clone()
650        } else {
651            terminals_root.join(&terminal.backing_path)
652        };
653
654        let _ = std::fs::create_dir_all(
655            log_path
656                .parent()
657                .or_else(|| backing_path.parent())
658                .unwrap_or(&terminals_root),
659        );
660
661        // Record paths using the predicted ID so buffer creation can reuse them
662        let predicted_id = self.terminal_manager.next_terminal_id();
663        self.terminal_log_files
664            .insert(predicted_id, log_path.clone());
665        self.terminal_backing_files
666            .insert(predicted_id, backing_path.clone());
667
668        // Spawn the terminal with backing file for incremental scrollback
669        let terminal_id = match self.terminal_manager.spawn(
670            terminal.cols,
671            terminal.rows,
672            terminal.cwd.clone(),
673            Some(log_path.clone()),
674            Some(backing_path.clone()),
675        ) {
676            Ok(id) => id,
677            Err(e) => {
678                tracing::warn!(
679                    "Failed to restore terminal {}: {}",
680                    terminal.terminal_index,
681                    e
682                );
683                return None;
684            }
685        };
686
687        // Ensure maps keyed by actual ID
688        if terminal_id != predicted_id {
689            self.terminal_log_files
690                .insert(terminal_id, log_path.clone());
691            self.terminal_backing_files
692                .insert(terminal_id, backing_path.clone());
693            self.terminal_log_files.remove(&predicted_id);
694            self.terminal_backing_files.remove(&predicted_id);
695        }
696
697        // Create buffer for this terminal
698        let buffer_id = self.create_terminal_buffer_detached(terminal_id);
699
700        // Load backing file directly as read-only buffer (skip log replay)
701        // The backing file already contains complete terminal state from last session
702        self.load_terminal_backing_file_as_buffer(buffer_id, &backing_path);
703
704        Some(buffer_id)
705    }
706
707    /// Load a terminal backing file directly as a read-only buffer.
708    ///
709    /// This is used for fast session restore - we load the pre-rendered backing
710    /// file instead of replaying the raw log through the VTE parser.
711    fn load_terminal_backing_file_as_buffer(&mut self, buffer_id: BufferId, backing_path: &Path) {
712        // Check if backing file exists; if not, terminal starts empty
713        if !backing_path.exists() {
714            return;
715        }
716
717        let large_file_threshold = self.config.editor.large_file_threshold_bytes as usize;
718        if let Ok(new_state) = EditorState::from_file_with_languages(
719            backing_path,
720            self.terminal_width,
721            self.terminal_height,
722            large_file_threshold,
723            &self.grammar_registry,
724            &self.config.languages,
725        ) {
726            if let Some(state) = self.buffers.get_mut(&buffer_id) {
727                *state = new_state;
728                // Move cursor to end of buffer
729                let total = state.buffer.total_bytes();
730                state.primary_cursor_mut().position = total;
731                // Terminal buffers should never be considered "modified"
732                state.buffer.set_modified(false);
733                // Start in scrollback mode (editing disabled)
734                state.editing_disabled = true;
735                state.margins.set_line_numbers(false);
736            }
737        }
738    }
739
740    /// Internal helper to open a file and return its buffer ID
741    fn open_file_internal(&mut self, path: &Path) -> Result<BufferId, SessionError> {
742        // Check if file is already open
743        for (buffer_id, metadata) in &self.buffer_metadata {
744            if let Some(file_path) = metadata.file_path() {
745                if file_path == path {
746                    return Ok(*buffer_id);
747                }
748            }
749        }
750
751        // File not open, open it using the Editor's open_file method
752        self.open_file(path).map_err(SessionError::Io)
753    }
754
755    /// Recursively restore the split layout from a serialized tree
756    fn restore_split_node(
757        &mut self,
758        node: &SerializedSplitNode,
759        path_to_buffer: &HashMap<PathBuf, BufferId>,
760        terminal_buffers: &HashMap<usize, BufferId>,
761        split_states: &HashMap<usize, SerializedSplitViewState>,
762        split_id_map: &mut HashMap<usize, SplitId>,
763        is_first_leaf: bool,
764    ) {
765        match node {
766            SerializedSplitNode::Leaf {
767                file_path,
768                split_id,
769            } => {
770                // Get the buffer for this file, or use the default buffer
771                let buffer_id = file_path
772                    .as_ref()
773                    .and_then(|p| path_to_buffer.get(p).copied())
774                    .unwrap_or(self.active_buffer());
775
776                let current_split_id = if is_first_leaf {
777                    // First leaf reuses the existing split
778                    let split_id_val = self.split_manager.active_split();
779                    let _ = self.split_manager.set_split_buffer(split_id_val, buffer_id);
780                    split_id_val
781                } else {
782                    // Non-first leaves use the active split (created by split_active)
783                    self.split_manager.active_split()
784                };
785
786                // Map old split ID to new one
787                split_id_map.insert(*split_id, current_split_id);
788
789                // Restore the view state for this split
790                self.restore_split_view_state(
791                    current_split_id,
792                    *split_id,
793                    split_states,
794                    path_to_buffer,
795                    terminal_buffers,
796                );
797            }
798            SerializedSplitNode::Terminal {
799                terminal_index,
800                split_id,
801            } => {
802                let buffer_id = terminal_buffers
803                    .get(terminal_index)
804                    .copied()
805                    .unwrap_or(self.active_buffer());
806
807                let current_split_id = if is_first_leaf {
808                    let split_id_val = self.split_manager.active_split();
809                    let _ = self.split_manager.set_split_buffer(split_id_val, buffer_id);
810                    split_id_val
811                } else {
812                    self.split_manager.active_split()
813                };
814
815                split_id_map.insert(*split_id, current_split_id);
816
817                let _ = self
818                    .split_manager
819                    .set_split_buffer(current_split_id, buffer_id);
820
821                self.restore_split_view_state(
822                    current_split_id,
823                    *split_id,
824                    split_states,
825                    path_to_buffer,
826                    terminal_buffers,
827                );
828            }
829            SerializedSplitNode::Split {
830                direction,
831                first,
832                second,
833                ratio,
834                split_id,
835            } => {
836                // First, restore the first child (it uses the current active split)
837                self.restore_split_node(
838                    first,
839                    path_to_buffer,
840                    terminal_buffers,
841                    split_states,
842                    split_id_map,
843                    is_first_leaf,
844                );
845
846                // Get the buffer for the second child's first leaf
847                let second_buffer_id =
848                    get_first_leaf_buffer(second, path_to_buffer, terminal_buffers)
849                        .unwrap_or(self.active_buffer());
850
851                // Convert direction
852                let split_direction = match direction {
853                    SerializedSplitDirection::Horizontal => SplitDirection::Horizontal,
854                    SerializedSplitDirection::Vertical => SplitDirection::Vertical,
855                };
856
857                // Create the split for the second child
858                match self
859                    .split_manager
860                    .split_active(split_direction, second_buffer_id, *ratio)
861                {
862                    Ok(new_split_id) => {
863                        // Create view state for the new split
864                        let mut view_state = SplitViewState::with_buffer(
865                            self.terminal_width,
866                            self.terminal_height,
867                            second_buffer_id,
868                        );
869                        view_state.viewport.line_wrap_enabled = self.config.editor.line_wrap;
870                        self.split_view_states.insert(new_split_id, view_state);
871
872                        // Map the container split ID (though we mainly care about leaves)
873                        split_id_map.insert(*split_id, new_split_id);
874
875                        // Recursively restore the second child (it's now in the new split)
876                        self.restore_split_node(
877                            second,
878                            path_to_buffer,
879                            terminal_buffers,
880                            split_states,
881                            split_id_map,
882                            false,
883                        );
884                    }
885                    Err(e) => {
886                        tracing::error!("Failed to create split during session restore: {}", e);
887                    }
888                }
889            }
890        }
891    }
892
893    /// Restore view state for a specific split
894    fn restore_split_view_state(
895        &mut self,
896        current_split_id: SplitId,
897        saved_split_id: usize,
898        split_states: &HashMap<usize, SerializedSplitViewState>,
899        path_to_buffer: &HashMap<PathBuf, BufferId>,
900        terminal_buffers: &HashMap<usize, BufferId>,
901    ) {
902        // Try to find the saved state for this split
903        let Some(split_state) = split_states.get(&saved_split_id) else {
904            return;
905        };
906
907        let Some(view_state) = self.split_view_states.get_mut(&current_split_id) else {
908            return;
909        };
910
911        let mut active_buffer_id: Option<BufferId> = None;
912
913        if !split_state.open_tabs.is_empty() {
914            for tab in &split_state.open_tabs {
915                match tab {
916                    SerializedTabRef::File(rel_path) => {
917                        if let Some(&buffer_id) = path_to_buffer.get(rel_path) {
918                            if !view_state.open_buffers.contains(&buffer_id) {
919                                view_state.open_buffers.push(buffer_id);
920                            }
921                            if terminal_buffers.values().any(|&tid| tid == buffer_id) {
922                                view_state.viewport.line_wrap_enabled = false;
923                            }
924                        }
925                    }
926                    SerializedTabRef::Terminal(index) => {
927                        if let Some(&buffer_id) = terminal_buffers.get(index) {
928                            if !view_state.open_buffers.contains(&buffer_id) {
929                                view_state.open_buffers.push(buffer_id);
930                            }
931                            view_state.viewport.line_wrap_enabled = false;
932                        }
933                    }
934                }
935            }
936
937            if let Some(active_idx) = split_state.active_tab_index {
938                if let Some(tab) = split_state.open_tabs.get(active_idx) {
939                    active_buffer_id = match tab {
940                        SerializedTabRef::File(rel) => path_to_buffer.get(rel).copied(),
941                        SerializedTabRef::Terminal(index) => terminal_buffers.get(index).copied(),
942                    };
943                }
944            }
945        } else {
946            // Backward compatibility path using open_files/active_file_index
947            for rel_path in &split_state.open_files {
948                if let Some(&buffer_id) = path_to_buffer.get(rel_path) {
949                    if !view_state.open_buffers.contains(&buffer_id) {
950                        view_state.open_buffers.push(buffer_id);
951                    }
952                }
953            }
954
955            let active_file_path = split_state.open_files.get(split_state.active_file_index);
956            active_buffer_id =
957                active_file_path.and_then(|rel_path| path_to_buffer.get(rel_path).copied());
958        }
959
960        // Restore cursor and scroll for the active file
961        if let Some(active_id) = active_buffer_id {
962            // Find the file state for the active buffer
963            for (rel_path, file_state) in &split_state.file_states {
964                let buffer_for_path = path_to_buffer.get(rel_path).copied();
965                if buffer_for_path == Some(active_id) {
966                    if let Some(buffer) = self.buffers.get(&active_id) {
967                        let max_pos = buffer.buffer.len();
968                        let cursor_pos = file_state.cursor.position.min(max_pos);
969
970                        // Set cursor in SplitViewState
971                        view_state.cursors.primary_mut().position = cursor_pos;
972                        view_state.cursors.primary_mut().anchor =
973                            file_state.cursor.anchor.map(|a| a.min(max_pos));
974                        view_state.cursors.primary_mut().sticky_column =
975                            file_state.cursor.sticky_column;
976
977                        // Set scroll position
978                        view_state.viewport.top_byte = file_state.scroll.top_byte.min(max_pos);
979                        view_state.viewport.top_view_line_offset =
980                            file_state.scroll.top_view_line_offset;
981                        view_state.viewport.left_column = file_state.scroll.left_column;
982                        // Mark viewport to skip sync on first resize after session restore
983                        // This prevents ensure_visible from overwriting the restored scroll position
984                        view_state.viewport.set_skip_resize_sync();
985
986                        tracing::trace!(
987                            "Restored SplitViewState for {:?}: cursor={}, top_byte={}",
988                            rel_path,
989                            cursor_pos,
990                            view_state.viewport.top_byte
991                        );
992                    }
993
994                    // Also set cursor in EditorState (authoritative for cursors)
995                    if let Some(editor_state) = self.buffers.get_mut(&active_id) {
996                        let max_pos = editor_state.buffer.len();
997                        let cursor_pos = file_state.cursor.position.min(max_pos);
998                        editor_state.cursors.primary_mut().position = cursor_pos;
999                        editor_state.cursors.primary_mut().anchor =
1000                            file_state.cursor.anchor.map(|a| a.min(max_pos));
1001                        editor_state.cursors.primary_mut().sticky_column =
1002                            file_state.cursor.sticky_column;
1003                        // Note: viewport is now exclusively owned by SplitViewState (restored above)
1004                    }
1005                    break;
1006                }
1007            }
1008
1009            // Set this buffer as active in the split
1010            let _ = self
1011                .split_manager
1012                .set_split_buffer(current_split_id, active_id);
1013        }
1014
1015        // Restore view mode
1016        view_state.view_mode = match split_state.view_mode {
1017            SerializedViewMode::Source => ViewMode::Source,
1018            SerializedViewMode::Compose => ViewMode::Compose,
1019        };
1020        view_state.compose_width = split_state.compose_width;
1021        view_state.tab_scroll_offset = split_state.tab_scroll_offset;
1022    }
1023}
1024
1025/// Helper: Get the buffer ID from the first leaf node in a split tree
1026fn get_first_leaf_buffer(
1027    node: &SerializedSplitNode,
1028    path_to_buffer: &HashMap<PathBuf, BufferId>,
1029    terminal_buffers: &HashMap<usize, BufferId>,
1030) -> Option<BufferId> {
1031    match node {
1032        SerializedSplitNode::Leaf { file_path, .. } => file_path
1033            .as_ref()
1034            .and_then(|p| path_to_buffer.get(p).copied()),
1035        SerializedSplitNode::Terminal { terminal_index, .. } => {
1036            terminal_buffers.get(terminal_index).copied()
1037        }
1038        SerializedSplitNode::Split { first, .. } => {
1039            get_first_leaf_buffer(first, path_to_buffer, terminal_buffers)
1040        }
1041    }
1042}
1043
1044// ============================================================================
1045// Serialization helpers
1046// ============================================================================
1047
1048fn serialize_split_node(
1049    node: &SplitNode,
1050    buffer_metadata: &HashMap<BufferId, super::types::BufferMetadata>,
1051    working_dir: &Path,
1052    terminal_buffers: &HashMap<BufferId, TerminalId>,
1053    terminal_indices: &HashMap<TerminalId, usize>,
1054) -> SerializedSplitNode {
1055    match node {
1056        SplitNode::Leaf {
1057            buffer_id,
1058            split_id,
1059        } => {
1060            if let Some(terminal_id) = terminal_buffers.get(buffer_id) {
1061                if let Some(index) = terminal_indices.get(terminal_id) {
1062                    return SerializedSplitNode::Terminal {
1063                        terminal_index: *index,
1064                        split_id: split_id.0,
1065                    };
1066                }
1067            }
1068
1069            let file_path = buffer_metadata
1070                .get(buffer_id)
1071                .and_then(|meta| meta.file_path())
1072                .and_then(|abs_path| {
1073                    abs_path
1074                        .strip_prefix(working_dir)
1075                        .ok()
1076                        .map(|p| p.to_path_buf())
1077                });
1078
1079            SerializedSplitNode::Leaf {
1080                file_path,
1081                split_id: split_id.0,
1082            }
1083        }
1084        SplitNode::Split {
1085            direction,
1086            first,
1087            second,
1088            ratio,
1089            split_id,
1090        } => SerializedSplitNode::Split {
1091            direction: match direction {
1092                SplitDirection::Horizontal => SerializedSplitDirection::Horizontal,
1093                SplitDirection::Vertical => SerializedSplitDirection::Vertical,
1094            },
1095            first: Box::new(serialize_split_node(
1096                first,
1097                buffer_metadata,
1098                working_dir,
1099                terminal_buffers,
1100                terminal_indices,
1101            )),
1102            second: Box::new(serialize_split_node(
1103                second,
1104                buffer_metadata,
1105                working_dir,
1106                terminal_buffers,
1107                terminal_indices,
1108            )),
1109            ratio: *ratio,
1110            split_id: split_id.0,
1111        },
1112    }
1113}
1114
1115fn serialize_split_view_state(
1116    view_state: &crate::view::split::SplitViewState,
1117    buffer_metadata: &HashMap<BufferId, super::types::BufferMetadata>,
1118    working_dir: &Path,
1119    active_buffer: Option<BufferId>,
1120    terminal_buffers: &HashMap<BufferId, TerminalId>,
1121    terminal_indices: &HashMap<TerminalId, usize>,
1122) -> SerializedSplitViewState {
1123    let mut open_tabs = Vec::new();
1124    let mut open_files = Vec::new();
1125    let mut active_tab_index = None;
1126
1127    for buffer_id in &view_state.open_buffers {
1128        let tab_index = open_tabs.len();
1129        if let Some(terminal_id) = terminal_buffers.get(buffer_id) {
1130            if let Some(idx) = terminal_indices.get(terminal_id) {
1131                open_tabs.push(SerializedTabRef::Terminal(*idx));
1132                if Some(*buffer_id) == active_buffer {
1133                    active_tab_index = Some(tab_index);
1134                }
1135                continue;
1136            }
1137        }
1138
1139        if let Some(rel_path) = buffer_metadata
1140            .get(buffer_id)
1141            .and_then(|meta| meta.file_path())
1142            .and_then(|abs_path| abs_path.strip_prefix(working_dir).ok())
1143        {
1144            open_tabs.push(SerializedTabRef::File(rel_path.to_path_buf()));
1145            open_files.push(rel_path.to_path_buf());
1146            if Some(*buffer_id) == active_buffer {
1147                active_tab_index = Some(tab_index);
1148            }
1149        }
1150    }
1151
1152    // Derive active_file_index for backward compatibility
1153    let active_file_index = active_tab_index
1154        .and_then(|idx| open_tabs.get(idx))
1155        .and_then(|tab| match tab {
1156            SerializedTabRef::File(path) => {
1157                Some(open_files.iter().position(|p| p == path).unwrap_or(0))
1158            }
1159            _ => None,
1160        })
1161        .unwrap_or(0);
1162
1163    // Serialize file states - only save cursor/scroll for the ACTIVE buffer if it is a file
1164    let mut file_states = HashMap::new();
1165    if let Some(active_id) = active_buffer {
1166        if let Some(meta) = buffer_metadata.get(&active_id) {
1167            if let Some(abs_path) = meta.file_path() {
1168                if let Ok(rel_path) = abs_path.strip_prefix(working_dir) {
1169                    let primary_cursor = view_state.cursors.primary();
1170
1171                    file_states.insert(
1172                        rel_path.to_path_buf(),
1173                        SerializedFileState {
1174                            cursor: SerializedCursor {
1175                                position: primary_cursor.position,
1176                                anchor: primary_cursor.anchor,
1177                                sticky_column: primary_cursor.sticky_column,
1178                            },
1179                            additional_cursors: view_state
1180                                .cursors
1181                                .iter()
1182                                .skip(1) // Skip primary
1183                                .map(|(_, cursor)| SerializedCursor {
1184                                    position: cursor.position,
1185                                    anchor: cursor.anchor,
1186                                    sticky_column: cursor.sticky_column,
1187                                })
1188                                .collect(),
1189                            scroll: SerializedScroll {
1190                                top_byte: view_state.viewport.top_byte,
1191                                top_view_line_offset: view_state.viewport.top_view_line_offset,
1192                                left_column: view_state.viewport.left_column,
1193                            },
1194                        },
1195                    );
1196                }
1197            }
1198        }
1199    }
1200
1201    SerializedSplitViewState {
1202        open_tabs,
1203        active_tab_index,
1204        open_files,
1205        active_file_index,
1206        file_states,
1207        tab_scroll_offset: view_state.tab_scroll_offset,
1208        view_mode: match view_state.view_mode {
1209            ViewMode::Source => SerializedViewMode::Source,
1210            ViewMode::Compose => SerializedViewMode::Compose,
1211        },
1212        compose_width: view_state.compose_width,
1213    }
1214}
1215
1216fn serialize_bookmarks(
1217    bookmarks: &HashMap<char, Bookmark>,
1218    buffer_metadata: &HashMap<BufferId, super::types::BufferMetadata>,
1219    working_dir: &Path,
1220) -> HashMap<char, SerializedBookmark> {
1221    bookmarks
1222        .iter()
1223        .filter_map(|(key, bookmark)| {
1224            buffer_metadata
1225                .get(&bookmark.buffer_id)
1226                .and_then(|meta| meta.file_path())
1227                .and_then(|abs_path| {
1228                    abs_path.strip_prefix(working_dir).ok().map(|rel_path| {
1229                        (
1230                            *key,
1231                            SerializedBookmark {
1232                                file_path: rel_path.to_path_buf(),
1233                                position: bookmark.position,
1234                            },
1235                        )
1236                    })
1237                })
1238        })
1239        .collect()
1240}
1241
1242/// Collect all unique file paths from split_states
1243fn collect_file_paths_from_states(
1244    split_states: &HashMap<usize, SerializedSplitViewState>,
1245) -> Vec<PathBuf> {
1246    let mut paths = Vec::new();
1247    for state in split_states.values() {
1248        if !state.open_tabs.is_empty() {
1249            for tab in &state.open_tabs {
1250                if let SerializedTabRef::File(path) = tab {
1251                    if !paths.contains(path) {
1252                        paths.push(path.clone());
1253                    }
1254                }
1255            }
1256        } else {
1257            for path in &state.open_files {
1258                if !paths.contains(path) {
1259                    paths.push(path.clone());
1260                }
1261            }
1262        }
1263    }
1264    paths
1265}
1266
1267/// Get list of expanded directories from a FileTreeView
1268fn get_expanded_dirs(
1269    explorer: &crate::view::file_tree::FileTreeView,
1270    working_dir: &Path,
1271) -> Vec<PathBuf> {
1272    let mut expanded = Vec::new();
1273    let tree = explorer.tree();
1274
1275    // Iterate through all nodes and collect expanded directories
1276    for node in tree.all_nodes() {
1277        if node.is_expanded() && node.is_dir() {
1278            // Get the path and make it relative to working_dir
1279            if let Ok(rel_path) = node.entry.path.strip_prefix(working_dir) {
1280                expanded.push(rel_path.to_path_buf());
1281            }
1282        }
1283    }
1284
1285    expanded
1286}