fresh/
session.rs

1//! Session persistence for per-project editor state
2//!
3//! Saves and restores:
4//! - Split layout and open files
5//! - Cursor and scroll positions per split per file
6//! - File explorer state
7//! - Search/replace history and options
8//! - Bookmarks
9//!
10//! ## Storage
11//!
12//! Sessions are stored in `$XDG_DATA_HOME/fresh/sessions/{encoded_path}.json`
13//! where `{encoded_path}` is the working directory path with:
14//! - Path separators (`/`) replaced with underscores (`_`)
15//! - Special characters percent-encoded as `%XX`
16//!
17//! Example: `/home/user/my project` becomes `home_user_my%20project.json`
18//!
19//! The encoding is fully reversible using `decode_filename_to_path()`.
20//!
21//! ## Crash Resistance
22//!
23//! Uses atomic writes: write to temp file, then rename.
24//! This ensures the session file is never left in a corrupted state.
25
26use serde::{Deserialize, Serialize};
27use std::collections::HashMap;
28use std::io::{self, Write};
29use std::path::{Path, PathBuf};
30use std::time::{SystemTime, UNIX_EPOCH};
31
32use crate::input::input_history::get_data_dir;
33
34/// Current session file format version
35pub const SESSION_VERSION: u32 = 1;
36
37/// Current per-file session version
38pub const FILE_SESSION_VERSION: u32 = 1;
39
40/// Persisted session state for a working directory
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct Session {
43    /// Schema version for future migrations
44    pub version: u32,
45
46    /// Working directory this session belongs to (for validation)
47    pub working_dir: PathBuf,
48
49    /// Split layout tree
50    pub split_layout: SerializedSplitNode,
51
52    /// Active split ID
53    pub active_split_id: usize,
54
55    /// Per-split view states (keyed by split_id)
56    pub split_states: HashMap<usize, SerializedSplitViewState>,
57
58    /// Editor config overrides (toggles that differ from defaults)
59    #[serde(default)]
60    pub config_overrides: SessionConfigOverrides,
61
62    /// File explorer state
63    pub file_explorer: FileExplorerState,
64
65    /// Input histories (search, replace, command palette, etc.)
66    #[serde(default)]
67    pub histories: SessionHistories,
68
69    /// Search options (persist across searches within session)
70    #[serde(default)]
71    pub search_options: SearchOptions,
72
73    /// Bookmarks (character key -> file position)
74    #[serde(default)]
75    pub bookmarks: HashMap<char, SerializedBookmark>,
76
77    /// Open terminal sessions (for restoration)
78    #[serde(default)]
79    pub terminals: Vec<SerializedTerminalSession>,
80
81    /// External files open in the session (files outside working_dir)
82    /// These are stored as absolute paths since they can't be made relative
83    #[serde(default)]
84    pub external_files: Vec<PathBuf>,
85
86    /// Timestamp when session was saved (Unix epoch seconds)
87    pub saved_at: u64,
88}
89
90/// Serializable split layout (mirrors SplitNode but with file paths instead of buffer IDs)
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub enum SerializedSplitNode {
93    Leaf {
94        /// File path relative to working_dir (None for scratch buffers)
95        file_path: Option<PathBuf>,
96        split_id: usize,
97    },
98    Terminal {
99        terminal_index: usize,
100        split_id: usize,
101    },
102    Split {
103        direction: SerializedSplitDirection,
104        first: Box<Self>,
105        second: Box<Self>,
106        ratio: f32,
107        split_id: usize,
108    },
109}
110
111#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
112pub enum SerializedSplitDirection {
113    Horizontal,
114    Vertical,
115}
116
117/// Per-split view state
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct SerializedSplitViewState {
120    /// Open tabs in tab order (files or terminals)
121    #[serde(default)]
122    pub open_tabs: Vec<SerializedTabRef>,
123
124    /// Active tab index in open_tabs (if present)
125    #[serde(default)]
126    pub active_tab_index: Option<usize>,
127
128    /// Open files in tab order (paths relative to working_dir)
129    /// Deprecated; retained for backward compatibility.
130    #[serde(default)]
131    pub open_files: Vec<PathBuf>,
132
133    /// Active file index in open_files
134    #[serde(default)]
135    pub active_file_index: usize,
136
137    /// Per-file cursor and scroll state
138    #[serde(default)]
139    pub file_states: HashMap<PathBuf, SerializedFileState>,
140
141    /// Tab scroll offset
142    #[serde(default)]
143    pub tab_scroll_offset: usize,
144
145    /// View mode
146    #[serde(default)]
147    pub view_mode: SerializedViewMode,
148
149    /// Compose width if in compose mode
150    #[serde(default)]
151    pub compose_width: Option<u16>,
152}
153
154/// Per-file state within a split
155#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct SerializedFileState {
157    /// Primary cursor position (byte offset)
158    pub cursor: SerializedCursor,
159
160    /// Additional cursors for multi-cursor
161    #[serde(default)]
162    pub additional_cursors: Vec<SerializedCursor>,
163
164    /// Scroll position (byte offset)
165    pub scroll: SerializedScroll,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct SerializedCursor {
170    /// Cursor position as byte offset from start of file
171    pub position: usize,
172    /// Selection anchor as byte offset (if selection active)
173    #[serde(default)]
174    pub anchor: Option<usize>,
175    /// Sticky column for vertical movement (character column)
176    #[serde(default)]
177    pub sticky_column: usize,
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct SerializedScroll {
182    /// Top visible position as byte offset
183    pub top_byte: usize,
184    /// Virtual line offset within the top line (for wrapped lines)
185    #[serde(default)]
186    pub top_view_line_offset: usize,
187    /// Left column offset (for horizontal scroll)
188    #[serde(default)]
189    pub left_column: usize,
190}
191
192#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
193pub enum SerializedViewMode {
194    #[default]
195    Source,
196    Compose,
197}
198
199/// Config overrides that differ from base config
200#[derive(Debug, Clone, Default, Serialize, Deserialize)]
201pub struct SessionConfigOverrides {
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub line_numbers: Option<bool>,
204    #[serde(default, skip_serializing_if = "Option::is_none")]
205    pub relative_line_numbers: Option<bool>,
206    #[serde(default, skip_serializing_if = "Option::is_none")]
207    pub line_wrap: Option<bool>,
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub syntax_highlighting: Option<bool>,
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub enable_inlay_hints: Option<bool>,
212    #[serde(default, skip_serializing_if = "Option::is_none")]
213    pub mouse_enabled: Option<bool>,
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub menu_bar_hidden: Option<bool>,
216}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct FileExplorerState {
220    pub visible: bool,
221    #[serde(default)]
222    pub width_percent: f32,
223    /// Expanded directories (relative paths)
224    #[serde(default)]
225    pub expanded_dirs: Vec<PathBuf>,
226    /// Scroll offset
227    #[serde(default)]
228    pub scroll_offset: usize,
229    /// Show hidden files (fixes #569)
230    #[serde(default)]
231    pub show_hidden: bool,
232    /// Show gitignored files (fixes #569)
233    #[serde(default)]
234    pub show_gitignored: bool,
235}
236
237impl Default for FileExplorerState {
238    fn default() -> Self {
239        Self {
240            visible: false,
241            width_percent: 0.3,
242            expanded_dirs: Vec::new(),
243            scroll_offset: 0,
244            show_hidden: false,
245            show_gitignored: false,
246        }
247    }
248}
249
250/// Per-session input histories
251#[derive(Debug, Clone, Default, Serialize, Deserialize)]
252pub struct SessionHistories {
253    #[serde(default, skip_serializing_if = "Vec::is_empty")]
254    pub search: Vec<String>,
255    #[serde(default, skip_serializing_if = "Vec::is_empty")]
256    pub replace: Vec<String>,
257    #[serde(default, skip_serializing_if = "Vec::is_empty")]
258    pub command_palette: Vec<String>,
259    #[serde(default, skip_serializing_if = "Vec::is_empty")]
260    pub goto_line: Vec<String>,
261    #[serde(default, skip_serializing_if = "Vec::is_empty")]
262    pub open_file: Vec<String>,
263}
264
265/// Search options that persist across searches within a session
266#[derive(Debug, Clone, Default, Serialize, Deserialize)]
267pub struct SearchOptions {
268    #[serde(default)]
269    pub case_sensitive: bool,
270    #[serde(default)]
271    pub whole_word: bool,
272    #[serde(default)]
273    pub use_regex: bool,
274    #[serde(default)]
275    pub confirm_each: bool,
276}
277
278/// Serialized bookmark (file path + byte offset)
279#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct SerializedBookmark {
281    /// File path (relative to working_dir)
282    pub file_path: PathBuf,
283    /// Byte offset position in the file
284    pub position: usize,
285}
286
287/// Reference to an open tab (file path or terminal index)
288#[derive(Debug, Clone, Serialize, Deserialize)]
289pub enum SerializedTabRef {
290    File(PathBuf),
291    Terminal(usize),
292}
293
294/// Persisted metadata for a terminal session
295#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct SerializedTerminalSession {
297    pub terminal_index: usize,
298    pub cwd: Option<PathBuf>,
299    pub shell: String,
300    pub cols: u16,
301    pub rows: u16,
302    pub log_path: PathBuf,
303    pub backing_path: PathBuf,
304}
305
306// ============================================================================
307// Global file state persistence (per-file, not per-project)
308// ============================================================================
309
310/// Individual file state stored in its own file
311///
312/// Each source file's scroll/cursor state is stored in a separate JSON file
313/// at `$XDG_DATA_HOME/fresh/file_states/{encoded_path}.json`.
314/// This allows concurrent editors to safely update different files without
315/// conflicts.
316#[derive(Debug, Clone, Serialize, Deserialize)]
317pub struct PersistedFileState {
318    /// Schema version for future migrations
319    pub version: u32,
320
321    /// The file state (cursor, scroll, etc.)
322    pub state: SerializedFileState,
323
324    /// Timestamp when last saved (Unix epoch seconds)
325    pub saved_at: u64,
326}
327
328impl PersistedFileState {
329    fn new(state: SerializedFileState) -> Self {
330        Self {
331            version: FILE_SESSION_VERSION,
332            state,
333            saved_at: SystemTime::now()
334                .duration_since(UNIX_EPOCH)
335                .unwrap_or_default()
336                .as_secs(),
337        }
338    }
339}
340
341/// Per-file session storage for scroll/cursor positions
342///
343/// Unlike project sessions which store file states relative to a working directory,
344/// this stores file states by absolute path so they persist across projects.
345/// This means opening the same file from different projects (or without a project)
346/// will restore the same scroll/cursor position.
347///
348/// Each file's state is stored in a separate JSON file at
349/// `$XDG_DATA_HOME/fresh/file_states/{encoded_path}.json` to avoid conflicts
350/// between concurrent editors. States are loaded lazily when opening files
351/// and saved immediately when closing files or saving the session.
352pub struct PersistedFileSession;
353
354impl PersistedFileSession {
355    /// Get the directory for file state files
356    fn states_dir() -> io::Result<PathBuf> {
357        Ok(get_data_dir()?.join("file_states"))
358    }
359
360    /// Get the state file path for a source file
361    fn state_file_path(source_path: &Path) -> io::Result<PathBuf> {
362        let canonical = source_path
363            .canonicalize()
364            .unwrap_or_else(|_| source_path.to_path_buf());
365        let filename = format!("{}.json", encode_path_for_filename(&canonical));
366        Ok(Self::states_dir()?.join(filename))
367    }
368
369    /// Load the state for a file by its absolute path (from disk)
370    pub fn load(path: &Path) -> Option<SerializedFileState> {
371        let state_path = match Self::state_file_path(path) {
372            Ok(p) => p,
373            Err(_) => return None,
374        };
375
376        if !state_path.exists() {
377            return None;
378        }
379
380        let content = match std::fs::read_to_string(&state_path) {
381            Ok(c) => c,
382            Err(_) => return None,
383        };
384
385        let persisted: PersistedFileState = match serde_json::from_str(&content) {
386            Ok(p) => p,
387            Err(_) => return None,
388        };
389
390        // Check version compatibility
391        if persisted.version > FILE_SESSION_VERSION {
392            return None;
393        }
394
395        Some(persisted.state)
396    }
397
398    /// Save the state for a file by its absolute path (to disk, atomic write)
399    pub fn save(path: &Path, state: SerializedFileState) {
400        let state_path = match Self::state_file_path(path) {
401            Ok(p) => p,
402            Err(e) => {
403                tracing::warn!("Failed to get state path for {:?}: {}", path, e);
404                return;
405            }
406        };
407
408        // Ensure directory exists
409        if let Some(parent) = state_path.parent() {
410            if let Err(e) = std::fs::create_dir_all(parent) {
411                tracing::warn!("Failed to create state dir: {}", e);
412                return;
413            }
414        }
415
416        let persisted = PersistedFileState::new(state);
417        let content = match serde_json::to_string_pretty(&persisted) {
418            Ok(c) => c,
419            Err(e) => {
420                tracing::warn!("Failed to serialize file state: {}", e);
421                return;
422            }
423        };
424
425        // Write atomically: temp file + rename
426        let temp_path = state_path.with_extension("json.tmp");
427
428        let write_result = (|| -> io::Result<()> {
429            let mut file = std::fs::File::create(&temp_path)?;
430            file.write_all(content.as_bytes())?;
431            file.sync_all()?;
432            std::fs::rename(&temp_path, &state_path)?;
433            Ok(())
434        })();
435
436        if let Err(e) = write_result {
437            tracing::warn!("Failed to save file state for {:?}: {}", path, e);
438        } else {
439            tracing::trace!("File state saved for {:?}", path);
440        }
441    }
442}
443
444// ============================================================================
445// Session file management
446// ============================================================================
447
448/// Get the sessions directory
449pub fn get_sessions_dir() -> io::Result<PathBuf> {
450    Ok(get_data_dir()?.join("sessions"))
451}
452
453/// Encode a path into a filesystem-safe filename using percent encoding
454///
455/// Keeps alphanumeric chars, `-`, `.`, `_` as-is.
456/// Replaces `/` with `_` for readability.
457/// Percent-encodes other special characters as %XX.
458///
459/// Example: `/home/user/my project` -> `home_user_my%20project`
460pub fn encode_path_for_filename(path: &Path) -> String {
461    let path_str = path.to_string_lossy();
462    let mut result = String::with_capacity(path_str.len() * 2);
463
464    for c in path_str.chars() {
465        match c {
466            // Path separators become underscores for readability
467            '/' | '\\' => result.push('_'),
468            // Safe chars pass through
469            c if c.is_ascii_alphanumeric() => result.push(c),
470            '-' | '.' => result.push(c),
471            // Underscore needs special handling to avoid collision with /
472            '_' => result.push_str("%5F"),
473            // Everything else gets percent-encoded
474            c => {
475                for byte in c.to_string().as_bytes() {
476                    result.push_str(&format!("%{:02X}", byte));
477                }
478            }
479        }
480    }
481
482    // Remove leading underscores (from leading /)
483    let result = result.trim_start_matches('_').to_string();
484
485    // Collapse multiple underscores
486    let mut final_result = String::with_capacity(result.len());
487    let mut last_was_underscore = false;
488    for c in result.chars() {
489        if c == '_' {
490            if !last_was_underscore {
491                final_result.push(c);
492            }
493            last_was_underscore = true;
494        } else {
495            final_result.push(c);
496            last_was_underscore = false;
497        }
498    }
499
500    if final_result.is_empty() {
501        final_result = "root".to_string();
502    }
503
504    final_result
505}
506
507/// Decode a filename back to the original path (for debugging/tooling)
508#[allow(dead_code)]
509pub fn decode_filename_to_path(encoded: &str) -> Option<PathBuf> {
510    if encoded == "root" {
511        return Some(PathBuf::from("/"));
512    }
513
514    let mut result = String::with_capacity(encoded.len() + 1);
515    // Re-add leading slash that was stripped during encoding
516    result.push('/');
517
518    let mut chars = encoded.chars().peekable();
519
520    while let Some(c) = chars.next() {
521        if c == '%' {
522            // Read two hex digits
523            let hex: String = chars.by_ref().take(2).collect();
524            if hex.len() == 2 {
525                if let Ok(byte) = u8::from_str_radix(&hex, 16) {
526                    result.push(byte as char);
527                }
528            }
529        } else if c == '_' {
530            result.push('/');
531        } else {
532            result.push(c);
533        }
534    }
535
536    Some(PathBuf::from(result))
537}
538
539/// Get the session file path for a working directory
540pub fn get_session_path(working_dir: &Path) -> io::Result<PathBuf> {
541    let canonical = working_dir
542        .canonicalize()
543        .unwrap_or_else(|_| working_dir.to_path_buf());
544    let filename = format!("{}.json", encode_path_for_filename(&canonical));
545    Ok(get_sessions_dir()?.join(filename))
546}
547
548/// Session error types
549#[derive(Debug)]
550pub enum SessionError {
551    Io(anyhow::Error),
552    Json(serde_json::Error),
553    WorkdirMismatch { expected: PathBuf, found: PathBuf },
554    VersionTooNew { version: u32, max_supported: u32 },
555}
556
557impl std::fmt::Display for SessionError {
558    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
559        match self {
560            Self::Io(e) => write!(f, "Session error: {}", e),
561            Self::Json(e) => write!(f, "JSON error: {}", e),
562            Self::WorkdirMismatch { expected, found } => {
563                write!(
564                    f,
565                    "Working directory mismatch: expected {:?}, found {:?}",
566                    expected, found
567                )
568            }
569            SessionError::VersionTooNew {
570                version,
571                max_supported,
572            } => {
573                write!(
574                    f,
575                    "Session version {} is newer than supported (max: {})",
576                    version, max_supported
577                )
578            }
579        }
580    }
581}
582
583impl std::error::Error for SessionError {
584    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
585        match self {
586            Self::Io(e) => e.source(),
587            Self::Json(e) => Some(e),
588            _ => None,
589        }
590    }
591}
592
593impl From<io::Error> for SessionError {
594    fn from(e: io::Error) -> Self {
595        SessionError::Io(e.into())
596    }
597}
598
599impl From<anyhow::Error> for SessionError {
600    fn from(e: anyhow::Error) -> Self {
601        SessionError::Io(e)
602    }
603}
604
605impl From<serde_json::Error> for SessionError {
606    fn from(e: serde_json::Error) -> Self {
607        SessionError::Json(e)
608    }
609}
610
611impl Session {
612    /// Load session for a working directory (if exists)
613    pub fn load(working_dir: &Path) -> Result<Option<Session>, SessionError> {
614        let path = get_session_path(working_dir)?;
615        tracing::debug!("Looking for session at {:?}", path);
616
617        if !path.exists() {
618            tracing::debug!("Session file does not exist");
619            return Ok(None);
620        }
621
622        tracing::debug!("Loading session from {:?}", path);
623        let content = std::fs::read_to_string(&path)?;
624        let session: Session = serde_json::from_str(&content)?;
625
626        tracing::debug!(
627            "Loaded session: version={}, split_states={}, active_split={}",
628            session.version,
629            session.split_states.len(),
630            session.active_split_id
631        );
632
633        // Validate working_dir matches (canonicalize both for comparison)
634        let expected = working_dir
635            .canonicalize()
636            .unwrap_or_else(|_| working_dir.to_path_buf());
637        let found = session
638            .working_dir
639            .canonicalize()
640            .unwrap_or_else(|_| session.working_dir.clone());
641
642        if expected != found {
643            tracing::warn!(
644                "Session working_dir mismatch: expected {:?}, found {:?}",
645                expected,
646                found
647            );
648            return Err(SessionError::WorkdirMismatch { expected, found });
649        }
650
651        // Check version compatibility
652        if session.version > SESSION_VERSION {
653            tracing::warn!(
654                "Session version {} is newer than supported {}",
655                session.version,
656                SESSION_VERSION
657            );
658            return Err(SessionError::VersionTooNew {
659                version: session.version,
660                max_supported: SESSION_VERSION,
661            });
662        }
663
664        Ok(Some(session))
665    }
666
667    /// Save session to file using atomic write (temp file + rename)
668    ///
669    /// This ensures the session file is never left in a corrupted state:
670    /// 1. Write to a temporary file in the same directory
671    /// 2. Sync to disk (fsync)
672    /// 3. Atomically rename to the final path
673    pub fn save(&self) -> Result<(), SessionError> {
674        let path = get_session_path(&self.working_dir)?;
675        tracing::debug!("Saving session to {:?}", path);
676
677        // Ensure directory exists
678        if let Some(parent) = path.parent() {
679            std::fs::create_dir_all(parent)?;
680        }
681
682        // Serialize to JSON
683        let content = serde_json::to_string_pretty(self)?;
684        tracing::trace!("Session JSON size: {} bytes", content.len());
685
686        // Write atomically: temp file + rename
687        let temp_path = path.with_extension("json.tmp");
688
689        // Write to temp file
690        {
691            let mut file = std::fs::File::create(&temp_path)?;
692            file.write_all(content.as_bytes())?;
693            file.sync_all()?; // Ensure data is on disk before rename
694        }
695
696        // Atomic rename
697        std::fs::rename(&temp_path, &path)?;
698        tracing::info!("Session saved to {:?}", path);
699
700        Ok(())
701    }
702
703    /// Delete session for a working directory
704    pub fn delete(working_dir: &Path) -> Result<(), SessionError> {
705        let path = get_session_path(working_dir)?;
706        if path.exists() {
707            std::fs::remove_file(path)?;
708        }
709        Ok(())
710    }
711
712    /// Create a new session with current timestamp
713    pub fn new(working_dir: PathBuf) -> Self {
714        Self {
715            version: SESSION_VERSION,
716            working_dir,
717            split_layout: SerializedSplitNode::Leaf {
718                file_path: None,
719                split_id: 0,
720            },
721            active_split_id: 0,
722            split_states: HashMap::new(),
723            config_overrides: SessionConfigOverrides::default(),
724            file_explorer: FileExplorerState::default(),
725            histories: SessionHistories::default(),
726            search_options: SearchOptions::default(),
727            bookmarks: HashMap::new(),
728            terminals: Vec::new(),
729            external_files: Vec::new(),
730            saved_at: SystemTime::now()
731                .duration_since(UNIX_EPOCH)
732                .unwrap_or_default()
733                .as_secs(),
734        }
735    }
736
737    /// Update the saved_at timestamp to now
738    pub fn touch(&mut self) {
739        self.saved_at = SystemTime::now()
740            .duration_since(UNIX_EPOCH)
741            .unwrap_or_default()
742            .as_secs();
743    }
744}
745
746#[cfg(test)]
747mod tests {
748    use super::*;
749
750    #[test]
751    fn test_session_path_percent_encoding() {
752        // Test basic path encoding - readable with underscores for separators
753        let encoded = encode_path_for_filename(Path::new("/home/user/project"));
754        assert_eq!(encoded, "home_user_project");
755        assert!(!encoded.contains('/')); // No slashes in encoded output
756
757        // Round-trip: encode then decode should give original path
758        let decoded = decode_filename_to_path(&encoded).unwrap();
759        assert_eq!(decoded, PathBuf::from("/home/user/project"));
760
761        // Different paths should give different encodings
762        let path1 = get_session_path(Path::new("/home/user/project")).unwrap();
763        let path2 = get_session_path(Path::new("/home/user/other")).unwrap();
764        assert_ne!(path1, path2);
765
766        // Same path should give same encoding
767        let path1_again = get_session_path(Path::new("/home/user/project")).unwrap();
768        assert_eq!(path1, path1_again);
769
770        // Filename should end with .json and be readable
771        let filename = path1.file_name().unwrap().to_str().unwrap();
772        assert!(filename.ends_with(".json"));
773        assert!(filename.starts_with("home_user_project"));
774    }
775
776    #[test]
777    fn test_percent_encoding_edge_cases() {
778        // Path with dashes (should pass through)
779        let encoded = encode_path_for_filename(Path::new("/home/user/my-project"));
780        assert_eq!(encoded, "home_user_my-project");
781
782        // Path with spaces (percent-encoded)
783        let encoded = encode_path_for_filename(Path::new("/home/user/my project"));
784        assert_eq!(encoded, "home_user_my%20project");
785        let decoded = decode_filename_to_path(&encoded).unwrap();
786        assert_eq!(decoded, PathBuf::from("/home/user/my project"));
787
788        // Path with underscores (percent-encoded to avoid collision with /)
789        let encoded = encode_path_for_filename(Path::new("/home/user/my_project"));
790        assert_eq!(encoded, "home_user_my%5Fproject");
791        let decoded = decode_filename_to_path(&encoded).unwrap();
792        assert_eq!(decoded, PathBuf::from("/home/user/my_project"));
793
794        // Root path
795        let encoded = encode_path_for_filename(Path::new("/"));
796        assert_eq!(encoded, "root");
797    }
798
799    #[test]
800    fn test_session_serialization() {
801        let session = Session::new(PathBuf::from("/home/user/test"));
802        let json = serde_json::to_string(&session).unwrap();
803        let restored: Session = serde_json::from_str(&json).unwrap();
804
805        assert_eq!(session.version, restored.version);
806        assert_eq!(session.working_dir, restored.working_dir);
807    }
808
809    #[test]
810    fn test_session_config_overrides_skip_none() {
811        let overrides = SessionConfigOverrides::default();
812        let json = serde_json::to_string(&overrides).unwrap();
813
814        // Empty overrides should serialize to empty object
815        assert_eq!(json, "{}");
816    }
817
818    #[test]
819    fn test_session_config_overrides_with_values() {
820        let overrides = SessionConfigOverrides {
821            line_wrap: Some(false),
822            ..Default::default()
823        };
824        let json = serde_json::to_string(&overrides).unwrap();
825
826        assert!(json.contains("line_wrap"));
827        assert!(!json.contains("line_numbers")); // None values skipped
828    }
829
830    #[test]
831    fn test_split_layout_serialization() {
832        // Create a nested split layout
833        let layout = SerializedSplitNode::Split {
834            direction: SerializedSplitDirection::Vertical,
835            first: Box::new(SerializedSplitNode::Leaf {
836                file_path: Some(PathBuf::from("src/main.rs")),
837                split_id: 1,
838            }),
839            second: Box::new(SerializedSplitNode::Leaf {
840                file_path: Some(PathBuf::from("src/lib.rs")),
841                split_id: 2,
842            }),
843            ratio: 0.5,
844            split_id: 0,
845        };
846
847        let json = serde_json::to_string(&layout).unwrap();
848        let restored: SerializedSplitNode = serde_json::from_str(&json).unwrap();
849
850        // Verify the restored layout matches
851        match restored {
852            SerializedSplitNode::Split {
853                direction,
854                ratio,
855                split_id,
856                ..
857            } => {
858                assert!(matches!(direction, SerializedSplitDirection::Vertical));
859                assert_eq!(ratio, 0.5);
860                assert_eq!(split_id, 0);
861            }
862            _ => panic!("Expected Split node"),
863        }
864    }
865
866    #[test]
867    fn test_file_state_serialization() {
868        let file_state = SerializedFileState {
869            cursor: SerializedCursor {
870                position: 1234,
871                anchor: Some(1000),
872                sticky_column: 15,
873            },
874            additional_cursors: vec![SerializedCursor {
875                position: 5000,
876                anchor: None,
877                sticky_column: 0,
878            }],
879            scroll: SerializedScroll {
880                top_byte: 500,
881                top_view_line_offset: 2,
882                left_column: 10,
883            },
884        };
885
886        let json = serde_json::to_string(&file_state).unwrap();
887        let restored: SerializedFileState = serde_json::from_str(&json).unwrap();
888
889        assert_eq!(restored.cursor.position, 1234);
890        assert_eq!(restored.cursor.anchor, Some(1000));
891        assert_eq!(restored.cursor.sticky_column, 15);
892        assert_eq!(restored.additional_cursors.len(), 1);
893        assert_eq!(restored.scroll.top_byte, 500);
894        assert_eq!(restored.scroll.left_column, 10);
895    }
896
897    #[test]
898    fn test_bookmark_serialization() {
899        let mut bookmarks = HashMap::new();
900        bookmarks.insert(
901            'a',
902            SerializedBookmark {
903                file_path: PathBuf::from("src/main.rs"),
904                position: 1234,
905            },
906        );
907        bookmarks.insert(
908            'b',
909            SerializedBookmark {
910                file_path: PathBuf::from("src/lib.rs"),
911                position: 5678,
912            },
913        );
914
915        let json = serde_json::to_string(&bookmarks).unwrap();
916        let restored: HashMap<char, SerializedBookmark> = serde_json::from_str(&json).unwrap();
917
918        assert_eq!(restored.len(), 2);
919        assert_eq!(restored.get(&'a').unwrap().position, 1234);
920        assert_eq!(
921            restored.get(&'b').unwrap().file_path,
922            PathBuf::from("src/lib.rs")
923        );
924    }
925
926    #[test]
927    fn test_search_options_serialization() {
928        let options = SearchOptions {
929            case_sensitive: true,
930            whole_word: true,
931            use_regex: false,
932            confirm_each: true,
933        };
934
935        let json = serde_json::to_string(&options).unwrap();
936        let restored: SearchOptions = serde_json::from_str(&json).unwrap();
937
938        assert!(restored.case_sensitive);
939        assert!(restored.whole_word);
940        assert!(!restored.use_regex);
941        assert!(restored.confirm_each);
942    }
943
944    #[test]
945    fn test_full_session_round_trip() {
946        let mut session = Session::new(PathBuf::from("/home/user/myproject"));
947
948        // Configure split layout
949        session.split_layout = SerializedSplitNode::Split {
950            direction: SerializedSplitDirection::Horizontal,
951            first: Box::new(SerializedSplitNode::Leaf {
952                file_path: Some(PathBuf::from("README.md")),
953                split_id: 1,
954            }),
955            second: Box::new(SerializedSplitNode::Leaf {
956                file_path: Some(PathBuf::from("Cargo.toml")),
957                split_id: 2,
958            }),
959            ratio: 0.6,
960            split_id: 0,
961        };
962        session.active_split_id = 1;
963
964        // Add split state
965        session.split_states.insert(
966            1,
967            SerializedSplitViewState {
968                open_tabs: vec![
969                    SerializedTabRef::File(PathBuf::from("README.md")),
970                    SerializedTabRef::File(PathBuf::from("src/lib.rs")),
971                ],
972                active_tab_index: Some(0),
973                open_files: vec![PathBuf::from("README.md"), PathBuf::from("src/lib.rs")],
974                active_file_index: 0,
975                file_states: HashMap::new(),
976                tab_scroll_offset: 0,
977                view_mode: SerializedViewMode::Source,
978                compose_width: None,
979            },
980        );
981
982        // Add bookmarks
983        session.bookmarks.insert(
984            'm',
985            SerializedBookmark {
986                file_path: PathBuf::from("src/main.rs"),
987                position: 100,
988            },
989        );
990
991        // Set search options
992        session.search_options.case_sensitive = true;
993        session.search_options.use_regex = true;
994
995        // Serialize and deserialize
996        let json = serde_json::to_string_pretty(&session).unwrap();
997        let restored: Session = serde_json::from_str(&json).unwrap();
998
999        // Verify everything matches
1000        assert_eq!(restored.version, SESSION_VERSION);
1001        assert_eq!(restored.working_dir, PathBuf::from("/home/user/myproject"));
1002        assert_eq!(restored.active_split_id, 1);
1003        assert!(restored.bookmarks.contains_key(&'m'));
1004        assert!(restored.search_options.case_sensitive);
1005        assert!(restored.search_options.use_regex);
1006
1007        // Verify split state
1008        let split_state = restored.split_states.get(&1).unwrap();
1009        assert_eq!(split_state.open_files.len(), 2);
1010        assert_eq!(split_state.open_files[0], PathBuf::from("README.md"));
1011    }
1012
1013    #[test]
1014    fn test_session_file_save_load() {
1015        use std::fs;
1016
1017        // Create a temporary directory for testing
1018        let temp_dir = std::env::temp_dir().join("fresh_session_test");
1019        let _ = fs::remove_dir_all(&temp_dir); // Clean up from previous runs
1020        fs::create_dir_all(&temp_dir).unwrap();
1021
1022        let session_path = temp_dir.join("test_session.json");
1023
1024        // Create a session
1025        let mut session = Session::new(temp_dir.clone());
1026        session.search_options.case_sensitive = true;
1027        session.bookmarks.insert(
1028            'x',
1029            SerializedBookmark {
1030                file_path: PathBuf::from("test.txt"),
1031                position: 42,
1032            },
1033        );
1034
1035        // Save it directly to test path
1036        let content = serde_json::to_string_pretty(&session).unwrap();
1037        let temp_path = session_path.with_extension("json.tmp");
1038        let mut file = std::fs::File::create(&temp_path).unwrap();
1039        std::io::Write::write_all(&mut file, content.as_bytes()).unwrap();
1040        file.sync_all().unwrap();
1041        std::fs::rename(&temp_path, &session_path).unwrap();
1042
1043        // Load it back
1044        let loaded_content = fs::read_to_string(&session_path).unwrap();
1045        let loaded: Session = serde_json::from_str(&loaded_content).unwrap();
1046
1047        // Verify
1048        assert_eq!(loaded.working_dir, temp_dir);
1049        assert!(loaded.search_options.case_sensitive);
1050        assert_eq!(loaded.bookmarks.get(&'x').unwrap().position, 42);
1051
1052        // Cleanup
1053        let _ = fs::remove_dir_all(&temp_dir);
1054    }
1055
1056    #[test]
1057    fn test_session_version_check() {
1058        let session = Session::new(PathBuf::from("/test"));
1059        assert_eq!(session.version, SESSION_VERSION);
1060
1061        // Serialize with a future version number
1062        let mut json_value: serde_json::Value = serde_json::to_value(&session).unwrap();
1063        json_value["version"] = serde_json::json!(999);
1064
1065        let json = serde_json::to_string(&json_value).unwrap();
1066        let restored: Session = serde_json::from_str(&json).unwrap();
1067
1068        // Should still deserialize, but version is 999
1069        assert_eq!(restored.version, 999);
1070    }
1071
1072    #[test]
1073    fn test_empty_session_histories() {
1074        let histories = SessionHistories::default();
1075        let json = serde_json::to_string(&histories).unwrap();
1076
1077        // Empty histories should serialize to empty object (due to skip_serializing_if)
1078        assert_eq!(json, "{}");
1079
1080        // But should deserialize back correctly
1081        let restored: SessionHistories = serde_json::from_str(&json).unwrap();
1082        assert!(restored.search.is_empty());
1083        assert!(restored.replace.is_empty());
1084    }
1085
1086    #[test]
1087    fn test_file_explorer_state() {
1088        let state = FileExplorerState {
1089            visible: true,
1090            width_percent: 0.25,
1091            expanded_dirs: vec![
1092                PathBuf::from("src"),
1093                PathBuf::from("src/app"),
1094                PathBuf::from("tests"),
1095            ],
1096            scroll_offset: 5,
1097            show_hidden: true,
1098            show_gitignored: false,
1099        };
1100
1101        let json = serde_json::to_string(&state).unwrap();
1102        let restored: FileExplorerState = serde_json::from_str(&json).unwrap();
1103
1104        assert!(restored.visible);
1105        assert_eq!(restored.width_percent, 0.25);
1106        assert_eq!(restored.expanded_dirs.len(), 3);
1107        assert_eq!(restored.scroll_offset, 5);
1108        assert!(restored.show_hidden);
1109        assert!(!restored.show_gitignored);
1110    }
1111}