1use 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
34pub const SESSION_VERSION: u32 = 1;
36
37pub const FILE_SESSION_VERSION: u32 = 1;
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct Session {
43 pub version: u32,
45
46 pub working_dir: PathBuf,
48
49 pub split_layout: SerializedSplitNode,
51
52 pub active_split_id: usize,
54
55 pub split_states: HashMap<usize, SerializedSplitViewState>,
57
58 #[serde(default)]
60 pub config_overrides: SessionConfigOverrides,
61
62 pub file_explorer: FileExplorerState,
64
65 #[serde(default)]
67 pub histories: SessionHistories,
68
69 #[serde(default)]
71 pub search_options: SearchOptions,
72
73 #[serde(default)]
75 pub bookmarks: HashMap<char, SerializedBookmark>,
76
77 #[serde(default)]
79 pub terminals: Vec<SerializedTerminalSession>,
80
81 #[serde(default)]
84 pub external_files: Vec<PathBuf>,
85
86 pub saved_at: u64,
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
92pub enum SerializedSplitNode {
93 Leaf {
94 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#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct SerializedSplitViewState {
120 #[serde(default)]
122 pub open_tabs: Vec<SerializedTabRef>,
123
124 #[serde(default)]
126 pub active_tab_index: Option<usize>,
127
128 #[serde(default)]
131 pub open_files: Vec<PathBuf>,
132
133 #[serde(default)]
135 pub active_file_index: usize,
136
137 #[serde(default)]
139 pub file_states: HashMap<PathBuf, SerializedFileState>,
140
141 #[serde(default)]
143 pub tab_scroll_offset: usize,
144
145 #[serde(default)]
147 pub view_mode: SerializedViewMode,
148
149 #[serde(default)]
151 pub compose_width: Option<u16>,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize)]
156pub struct SerializedFileState {
157 pub cursor: SerializedCursor,
159
160 #[serde(default)]
162 pub additional_cursors: Vec<SerializedCursor>,
163
164 pub scroll: SerializedScroll,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
169pub struct SerializedCursor {
170 pub position: usize,
172 #[serde(default)]
174 pub anchor: Option<usize>,
175 #[serde(default)]
177 pub sticky_column: usize,
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct SerializedScroll {
182 pub top_byte: usize,
184 #[serde(default)]
186 pub top_view_line_offset: usize,
187 #[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#[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 #[serde(default)]
225 pub expanded_dirs: Vec<PathBuf>,
226 #[serde(default)]
228 pub scroll_offset: usize,
229 #[serde(default)]
231 pub show_hidden: bool,
232 #[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#[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#[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#[derive(Debug, Clone, Serialize, Deserialize)]
280pub struct SerializedBookmark {
281 pub file_path: PathBuf,
283 pub position: usize,
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize)]
289pub enum SerializedTabRef {
290 File(PathBuf),
291 Terminal(usize),
292}
293
294#[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#[derive(Debug, Clone, Serialize, Deserialize)]
317pub struct PersistedFileState {
318 pub version: u32,
320
321 pub state: SerializedFileState,
323
324 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
341pub struct PersistedFileSession;
353
354impl PersistedFileSession {
355 fn states_dir() -> io::Result<PathBuf> {
357 Ok(get_data_dir()?.join("file_states"))
358 }
359
360 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 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 if persisted.version > FILE_SESSION_VERSION {
392 return None;
393 }
394
395 Some(persisted.state)
396 }
397
398 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 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 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
444pub fn get_sessions_dir() -> io::Result<PathBuf> {
450 Ok(get_data_dir()?.join("sessions"))
451}
452
453pub 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 '/' | '\\' => result.push('_'),
468 c if c.is_ascii_alphanumeric() => result.push(c),
470 '-' | '.' => result.push(c),
471 '_' => result.push_str("%5F"),
473 c => {
475 for byte in c.to_string().as_bytes() {
476 result.push_str(&format!("%{:02X}", byte));
477 }
478 }
479 }
480 }
481
482 let result = result.trim_start_matches('_').to_string();
484
485 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#[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 result.push('/');
517
518 let mut chars = encoded.chars().peekable();
519
520 while let Some(c) = chars.next() {
521 if c == '%' {
522 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
539pub 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#[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 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 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 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 pub fn save(&self) -> Result<(), SessionError> {
674 let path = get_session_path(&self.working_dir)?;
675 tracing::debug!("Saving session to {:?}", path);
676
677 if let Some(parent) = path.parent() {
679 std::fs::create_dir_all(parent)?;
680 }
681
682 let content = serde_json::to_string_pretty(self)?;
684 tracing::trace!("Session JSON size: {} bytes", content.len());
685
686 let temp_path = path.with_extension("json.tmp");
688
689 {
691 let mut file = std::fs::File::create(&temp_path)?;
692 file.write_all(content.as_bytes())?;
693 file.sync_all()?; }
695
696 std::fs::rename(&temp_path, &path)?;
698 tracing::info!("Session saved to {:?}", path);
699
700 Ok(())
701 }
702
703 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 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 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 let encoded = encode_path_for_filename(Path::new("/home/user/project"));
754 assert_eq!(encoded, "home_user_project");
755 assert!(!encoded.contains('/')); let decoded = decode_filename_to_path(&encoded).unwrap();
759 assert_eq!(decoded, PathBuf::from("/home/user/project"));
760
761 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 let path1_again = get_session_path(Path::new("/home/user/project")).unwrap();
768 assert_eq!(path1, path1_again);
769
770 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 let encoded = encode_path_for_filename(Path::new("/home/user/my-project"));
780 assert_eq!(encoded, "home_user_my-project");
781
782 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 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 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 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")); }
829
830 #[test]
831 fn test_split_layout_serialization() {
832 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 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 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 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 session.bookmarks.insert(
984 'm',
985 SerializedBookmark {
986 file_path: PathBuf::from("src/main.rs"),
987 position: 100,
988 },
989 );
990
991 session.search_options.case_sensitive = true;
993 session.search_options.use_regex = true;
994
995 let json = serde_json::to_string_pretty(&session).unwrap();
997 let restored: Session = serde_json::from_str(&json).unwrap();
998
999 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 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 let temp_dir = std::env::temp_dir().join("fresh_session_test");
1019 let _ = fs::remove_dir_all(&temp_dir); fs::create_dir_all(&temp_dir).unwrap();
1021
1022 let session_path = temp_dir.join("test_session.json");
1023
1024 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 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 let loaded_content = fs::read_to_string(&session_path).unwrap();
1045 let loaded: Session = serde_json::from_str(&loaded_content).unwrap();
1046
1047 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 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 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 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 assert_eq!(json, "{}");
1079
1080 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}