Skip to main content

fresh/app/
workspace.rs

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