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 WORKSPACE_VERSION: u32 = 1;
36
37pub const FILE_WORKSPACE_VERSION: u32 = 1;
39
40#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct Workspace {
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: WorkspaceConfigOverrides,
61
62 pub file_explorer: FileExplorerState,
64
65 #[serde(default)]
67 pub histories: WorkspaceHistories,
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<SerializedTerminalWorkspace>,
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 #[serde(default, skip_serializing_if = "Option::is_none")]
99 label: Option<String>,
100 },
101 Terminal {
102 terminal_index: usize,
103 split_id: usize,
104 #[serde(default, skip_serializing_if = "Option::is_none")]
106 label: Option<String>,
107 },
108 Split {
109 direction: SerializedSplitDirection,
110 first: Box<Self>,
111 second: Box<Self>,
112 ratio: f32,
113 split_id: usize,
114 },
115}
116
117#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
118pub enum SerializedSplitDirection {
119 Horizontal,
120 Vertical,
121}
122
123#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct SerializedSplitViewState {
126 #[serde(default)]
128 pub open_tabs: Vec<SerializedTabRef>,
129
130 #[serde(default)]
132 pub active_tab_index: Option<usize>,
133
134 #[serde(default)]
137 pub open_files: Vec<PathBuf>,
138
139 #[serde(default)]
141 pub active_file_index: usize,
142
143 #[serde(default)]
145 pub file_states: HashMap<PathBuf, SerializedFileState>,
146
147 #[serde(default)]
149 pub tab_scroll_offset: usize,
150
151 #[serde(default)]
153 pub view_mode: SerializedViewMode,
154
155 #[serde(default)]
157 pub compose_width: Option<u16>,
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct SerializedFileState {
163 pub cursor: SerializedCursor,
165
166 #[serde(default)]
168 pub additional_cursors: Vec<SerializedCursor>,
169
170 pub scroll: SerializedScroll,
172
173 #[serde(default)]
175 pub view_mode: SerializedViewMode,
176
177 #[serde(default)]
179 pub compose_width: Option<u16>,
180
181 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
183 pub plugin_state: HashMap<String, serde_json::Value>,
184
185 #[serde(default, skip_serializing_if = "Vec::is_empty")]
187 pub folds: Vec<SerializedFoldRange>,
188}
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct SerializedFoldRange {
193 pub header_line: usize,
195 pub end_line: usize,
197 #[serde(default)]
199 pub placeholder: Option<String>,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct SerializedCursor {
204 pub position: usize,
206 #[serde(default)]
208 pub anchor: Option<usize>,
209 #[serde(default)]
211 pub sticky_column: usize,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct SerializedScroll {
216 pub top_byte: usize,
218 #[serde(default)]
220 pub top_view_line_offset: usize,
221 #[serde(default)]
223 pub left_column: usize,
224}
225
226#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
227pub enum SerializedViewMode {
228 #[default]
229 Source,
230 Compose,
231}
232
233#[derive(Debug, Clone, Default, Serialize, Deserialize)]
235pub struct WorkspaceConfigOverrides {
236 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub line_numbers: Option<bool>,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub relative_line_numbers: Option<bool>,
240 #[serde(default, skip_serializing_if = "Option::is_none")]
241 pub line_wrap: Option<bool>,
242 #[serde(default, skip_serializing_if = "Option::is_none")]
243 pub syntax_highlighting: Option<bool>,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
245 pub enable_inlay_hints: Option<bool>,
246 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub mouse_enabled: Option<bool>,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
249 pub menu_bar_hidden: Option<bool>,
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct FileExplorerState {
254 pub visible: bool,
255 #[serde(default)]
256 pub width_percent: f32,
257 #[serde(default)]
259 pub expanded_dirs: Vec<PathBuf>,
260 #[serde(default)]
262 pub scroll_offset: usize,
263 #[serde(default)]
265 pub show_hidden: bool,
266 #[serde(default)]
268 pub show_gitignored: bool,
269}
270
271impl Default for FileExplorerState {
272 fn default() -> Self {
273 Self {
274 visible: false,
275 width_percent: 0.3,
276 expanded_dirs: Vec::new(),
277 scroll_offset: 0,
278 show_hidden: false,
279 show_gitignored: false,
280 }
281 }
282}
283
284#[derive(Debug, Clone, Default, Serialize, Deserialize)]
286pub struct WorkspaceHistories {
287 #[serde(default, skip_serializing_if = "Vec::is_empty")]
288 pub search: Vec<String>,
289 #[serde(default, skip_serializing_if = "Vec::is_empty")]
290 pub replace: Vec<String>,
291 #[serde(default, skip_serializing_if = "Vec::is_empty")]
292 pub command_palette: Vec<String>,
293 #[serde(default, skip_serializing_if = "Vec::is_empty")]
294 pub goto_line: Vec<String>,
295 #[serde(default, skip_serializing_if = "Vec::is_empty")]
296 pub open_file: Vec<String>,
297}
298
299#[derive(Debug, Clone, Default, Serialize, Deserialize)]
301pub struct SearchOptions {
302 #[serde(default)]
303 pub case_sensitive: bool,
304 #[serde(default)]
305 pub whole_word: bool,
306 #[serde(default)]
307 pub use_regex: bool,
308 #[serde(default)]
309 pub confirm_each: bool,
310}
311
312#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct SerializedBookmark {
315 pub file_path: PathBuf,
317 pub position: usize,
319}
320
321#[derive(Debug, Clone, Serialize, Deserialize)]
323pub enum SerializedTabRef {
324 File(PathBuf),
325 Terminal(usize),
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct SerializedTerminalWorkspace {
331 pub terminal_index: usize,
332 pub cwd: Option<PathBuf>,
333 pub shell: String,
334 pub cols: u16,
335 pub rows: u16,
336 pub log_path: PathBuf,
337 pub backing_path: PathBuf,
338}
339
340#[derive(Debug, Clone, Serialize, Deserialize)]
351pub struct PersistedFileState {
352 pub version: u32,
354
355 pub state: SerializedFileState,
357
358 pub saved_at: u64,
360}
361
362impl PersistedFileState {
363 fn new(state: SerializedFileState) -> Self {
364 Self {
365 version: FILE_WORKSPACE_VERSION,
366 state,
367 saved_at: SystemTime::now()
368 .duration_since(UNIX_EPOCH)
369 .unwrap_or_default()
370 .as_secs(),
371 }
372 }
373}
374
375pub struct PersistedFileWorkspace;
387
388impl PersistedFileWorkspace {
389 fn states_dir() -> io::Result<PathBuf> {
391 Ok(get_data_dir()?.join("file_states"))
392 }
393
394 fn state_file_path(source_path: &Path) -> io::Result<PathBuf> {
396 let canonical = source_path
397 .canonicalize()
398 .unwrap_or_else(|_| source_path.to_path_buf());
399 let filename = format!("{}.json", encode_path_for_filename(&canonical));
400 Ok(Self::states_dir()?.join(filename))
401 }
402
403 pub fn load(path: &Path) -> Option<SerializedFileState> {
405 let state_path = match Self::state_file_path(path) {
406 Ok(p) => p,
407 Err(_) => return None,
408 };
409
410 if !state_path.exists() {
411 return None;
412 }
413
414 let content = match std::fs::read_to_string(&state_path) {
415 Ok(c) => c,
416 Err(_) => return None,
417 };
418
419 let persisted: PersistedFileState = match serde_json::from_str(&content) {
420 Ok(p) => p,
421 Err(_) => return None,
422 };
423
424 if persisted.version > FILE_WORKSPACE_VERSION {
426 return None;
427 }
428
429 Some(persisted.state)
430 }
431
432 pub fn save(path: &Path, state: SerializedFileState) {
434 let state_path = match Self::state_file_path(path) {
435 Ok(p) => p,
436 Err(e) => {
437 tracing::warn!("Failed to get state path for {:?}: {}", path, e);
438 return;
439 }
440 };
441
442 if let Some(parent) = state_path.parent() {
444 if let Err(e) = std::fs::create_dir_all(parent) {
445 tracing::warn!("Failed to create state dir: {}", e);
446 return;
447 }
448 }
449
450 let persisted = PersistedFileState::new(state);
451 let content = match serde_json::to_string_pretty(&persisted) {
452 Ok(c) => c,
453 Err(e) => {
454 tracing::warn!("Failed to serialize file state: {}", e);
455 return;
456 }
457 };
458
459 let temp_path = state_path.with_extension("json.tmp");
461
462 let write_result = (|| -> io::Result<()> {
463 let mut file = std::fs::File::create(&temp_path)?;
464 file.write_all(content.as_bytes())?;
465 file.sync_all()?;
466 std::fs::rename(&temp_path, &state_path)?;
467 Ok(())
468 })();
469
470 if let Err(e) = write_result {
471 tracing::warn!("Failed to save file state for {:?}: {}", path, e);
472 } else {
473 tracing::trace!("File state saved for {:?}", path);
474 }
475 }
476}
477
478pub fn get_workspaces_dir() -> io::Result<PathBuf> {
484 Ok(get_data_dir()?.join("workspaces"))
485}
486
487pub fn encode_path_for_filename(path: &Path) -> String {
495 let path_str = path.to_string_lossy();
496 let mut result = String::with_capacity(path_str.len() * 2);
497
498 for c in path_str.chars() {
499 match c {
500 '/' | '\\' => result.push('_'),
502 c if c.is_ascii_alphanumeric() => result.push(c),
504 '-' | '.' => result.push(c),
505 '_' => result.push_str("%5F"),
507 c => {
509 for byte in c.to_string().as_bytes() {
510 result.push_str(&format!("%{:02X}", byte));
511 }
512 }
513 }
514 }
515
516 let result = result.trim_start_matches('_').to_string();
518
519 let mut final_result = String::with_capacity(result.len());
521 let mut last_was_underscore = false;
522 for c in result.chars() {
523 if c == '_' {
524 if !last_was_underscore {
525 final_result.push(c);
526 }
527 last_was_underscore = true;
528 } else {
529 final_result.push(c);
530 last_was_underscore = false;
531 }
532 }
533
534 if final_result.is_empty() {
535 final_result = "root".to_string();
536 }
537
538 final_result
539}
540
541#[allow(dead_code)]
543pub fn decode_filename_to_path(encoded: &str) -> Option<PathBuf> {
544 if encoded == "root" {
545 return Some(PathBuf::from("/"));
546 }
547
548 let mut result = String::with_capacity(encoded.len() + 1);
549 result.push('/');
551
552 let mut chars = encoded.chars().peekable();
553
554 while let Some(c) = chars.next() {
555 if c == '%' {
556 let hex: String = chars.by_ref().take(2).collect();
558 if hex.len() == 2 {
559 if let Ok(byte) = u8::from_str_radix(&hex, 16) {
560 result.push(byte as char);
561 }
562 }
563 } else if c == '_' {
564 result.push('/');
565 } else {
566 result.push(c);
567 }
568 }
569
570 Some(PathBuf::from(result))
571}
572
573pub fn get_workspace_path(working_dir: &Path) -> io::Result<PathBuf> {
575 let canonical = working_dir
576 .canonicalize()
577 .unwrap_or_else(|_| working_dir.to_path_buf());
578 let filename = format!("{}.json", encode_path_for_filename(&canonical));
579 Ok(get_workspaces_dir()?.join(filename))
580}
581
582#[derive(Debug)]
584pub enum WorkspaceError {
585 Io(anyhow::Error),
586 Json(serde_json::Error),
587 WorkdirMismatch { expected: PathBuf, found: PathBuf },
588 VersionTooNew { version: u32, max_supported: u32 },
589}
590
591impl std::fmt::Display for WorkspaceError {
592 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
593 match self {
594 Self::Io(e) => write!(f, "Workspace error: {}", e),
595 Self::Json(e) => write!(f, "JSON error: {}", e),
596 Self::WorkdirMismatch { expected, found } => {
597 write!(
598 f,
599 "Working directory mismatch: expected {:?}, found {:?}",
600 expected, found
601 )
602 }
603 WorkspaceError::VersionTooNew {
604 version,
605 max_supported,
606 } => {
607 write!(
608 f,
609 "Workspace version {} is newer than supported (max: {})",
610 version, max_supported
611 )
612 }
613 }
614 }
615}
616
617impl std::error::Error for WorkspaceError {
618 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
619 match self {
620 Self::Io(e) => e.source(),
621 Self::Json(e) => Some(e),
622 _ => None,
623 }
624 }
625}
626
627impl From<io::Error> for WorkspaceError {
628 fn from(e: io::Error) -> Self {
629 WorkspaceError::Io(e.into())
630 }
631}
632
633impl From<anyhow::Error> for WorkspaceError {
634 fn from(e: anyhow::Error) -> Self {
635 WorkspaceError::Io(e)
636 }
637}
638
639impl From<serde_json::Error> for WorkspaceError {
640 fn from(e: serde_json::Error) -> Self {
641 WorkspaceError::Json(e)
642 }
643}
644
645impl Workspace {
646 pub fn load(working_dir: &Path) -> Result<Option<Workspace>, WorkspaceError> {
648 let path = get_workspace_path(working_dir)?;
649 tracing::debug!("Looking for workspace at {:?}", path);
650
651 if !path.exists() {
652 tracing::debug!("Workspace file does not exist");
653 return Ok(None);
654 }
655
656 tracing::debug!("Loading workspace from {:?}", path);
657 let content = std::fs::read_to_string(&path)?;
658 let workspace: Workspace = serde_json::from_str(&content)?;
659
660 tracing::debug!(
661 "Loaded workspace: version={}, split_states={}, active_split={}",
662 workspace.version,
663 workspace.split_states.len(),
664 workspace.active_split_id
665 );
666
667 let expected = working_dir
669 .canonicalize()
670 .unwrap_or_else(|_| working_dir.to_path_buf());
671 let found = workspace
672 .working_dir
673 .canonicalize()
674 .unwrap_or_else(|_| workspace.working_dir.clone());
675
676 if expected != found {
677 tracing::warn!(
678 "Workspace working_dir mismatch: expected {:?}, found {:?}",
679 expected,
680 found
681 );
682 return Err(WorkspaceError::WorkdirMismatch { expected, found });
683 }
684
685 if workspace.version > WORKSPACE_VERSION {
687 tracing::warn!(
688 "Workspace version {} is newer than supported {}",
689 workspace.version,
690 WORKSPACE_VERSION
691 );
692 return Err(WorkspaceError::VersionTooNew {
693 version: workspace.version,
694 max_supported: WORKSPACE_VERSION,
695 });
696 }
697
698 Ok(Some(workspace))
699 }
700
701 pub fn save(&self) -> Result<(), WorkspaceError> {
708 let path = get_workspace_path(&self.working_dir)?;
709 tracing::debug!("Saving workspace to {:?}", path);
710
711 if let Some(parent) = path.parent() {
713 std::fs::create_dir_all(parent)?;
714 }
715
716 let content = serde_json::to_string_pretty(self)?;
718 tracing::trace!("Workspace JSON size: {} bytes", content.len());
719
720 let temp_path = path.with_extension("json.tmp");
722
723 {
725 let mut file = std::fs::File::create(&temp_path)?;
726 file.write_all(content.as_bytes())?;
727 file.sync_all()?; }
729
730 std::fs::rename(&temp_path, &path)?;
732 tracing::info!("Workspace saved to {:?}", path);
733
734 Ok(())
735 }
736
737 pub fn delete(working_dir: &Path) -> Result<(), WorkspaceError> {
739 let path = get_workspace_path(working_dir)?;
740 if path.exists() {
741 std::fs::remove_file(path)?;
742 }
743 Ok(())
744 }
745
746 pub fn new(working_dir: PathBuf) -> Self {
748 Self {
749 version: WORKSPACE_VERSION,
750 working_dir,
751 split_layout: SerializedSplitNode::Leaf {
752 file_path: None,
753 split_id: 0,
754 label: None,
755 },
756 active_split_id: 0,
757 split_states: HashMap::new(),
758 config_overrides: WorkspaceConfigOverrides::default(),
759 file_explorer: FileExplorerState::default(),
760 histories: WorkspaceHistories::default(),
761 search_options: SearchOptions::default(),
762 bookmarks: HashMap::new(),
763 terminals: Vec::new(),
764 external_files: Vec::new(),
765 saved_at: SystemTime::now()
766 .duration_since(UNIX_EPOCH)
767 .unwrap_or_default()
768 .as_secs(),
769 }
770 }
771
772 pub fn touch(&mut self) {
774 self.saved_at = SystemTime::now()
775 .duration_since(UNIX_EPOCH)
776 .unwrap_or_default()
777 .as_secs();
778 }
779}
780
781#[cfg(test)]
782mod tests {
783 use super::*;
784
785 #[test]
786 fn test_workspace_path_percent_encoding() {
787 let encoded = encode_path_for_filename(Path::new("/home/user/project"));
789 assert_eq!(encoded, "home_user_project");
790 assert!(!encoded.contains('/')); let decoded = decode_filename_to_path(&encoded).unwrap();
794 assert_eq!(decoded, PathBuf::from("/home/user/project"));
795
796 let path1 = get_workspace_path(Path::new("/home/user/project")).unwrap();
798 let path2 = get_workspace_path(Path::new("/home/user/other")).unwrap();
799 assert_ne!(path1, path2);
800
801 let path1_again = get_workspace_path(Path::new("/home/user/project")).unwrap();
803 assert_eq!(path1, path1_again);
804
805 let filename = path1.file_name().unwrap().to_str().unwrap();
807 assert!(filename.ends_with(".json"));
808 assert!(filename.starts_with("home_user_project"));
809 }
810
811 #[test]
812 fn test_percent_encoding_edge_cases() {
813 let encoded = encode_path_for_filename(Path::new("/home/user/my-project"));
815 assert_eq!(encoded, "home_user_my-project");
816
817 let encoded = encode_path_for_filename(Path::new("/home/user/my project"));
819 assert_eq!(encoded, "home_user_my%20project");
820 let decoded = decode_filename_to_path(&encoded).unwrap();
821 assert_eq!(decoded, PathBuf::from("/home/user/my project"));
822
823 let encoded = encode_path_for_filename(Path::new("/home/user/my_project"));
825 assert_eq!(encoded, "home_user_my%5Fproject");
826 let decoded = decode_filename_to_path(&encoded).unwrap();
827 assert_eq!(decoded, PathBuf::from("/home/user/my_project"));
828
829 let encoded = encode_path_for_filename(Path::new("/"));
831 assert_eq!(encoded, "root");
832 }
833
834 #[test]
835 fn test_workspace_serialization() {
836 let workspace = Workspace::new(PathBuf::from("/home/user/test"));
837 let json = serde_json::to_string(&workspace).unwrap();
838 let restored: Workspace = serde_json::from_str(&json).unwrap();
839
840 assert_eq!(workspace.version, restored.version);
841 assert_eq!(workspace.working_dir, restored.working_dir);
842 }
843
844 #[test]
845 fn test_workspace_config_overrides_skip_none() {
846 let overrides = WorkspaceConfigOverrides::default();
847 let json = serde_json::to_string(&overrides).unwrap();
848
849 assert_eq!(json, "{}");
851 }
852
853 #[test]
854 fn test_workspace_config_overrides_with_values() {
855 let overrides = WorkspaceConfigOverrides {
856 line_wrap: Some(false),
857 ..Default::default()
858 };
859 let json = serde_json::to_string(&overrides).unwrap();
860
861 assert!(json.contains("line_wrap"));
862 assert!(!json.contains("line_numbers")); }
864
865 #[test]
866 fn test_split_layout_serialization() {
867 let layout = SerializedSplitNode::Split {
869 direction: SerializedSplitDirection::Vertical,
870 first: Box::new(SerializedSplitNode::Leaf {
871 file_path: Some(PathBuf::from("src/main.rs")),
872 split_id: 1,
873 label: None,
874 }),
875 second: Box::new(SerializedSplitNode::Leaf {
876 file_path: Some(PathBuf::from("src/lib.rs")),
877 split_id: 2,
878 label: None,
879 }),
880 ratio: 0.5,
881 split_id: 0,
882 };
883
884 let json = serde_json::to_string(&layout).unwrap();
885 let restored: SerializedSplitNode = serde_json::from_str(&json).unwrap();
886
887 match restored {
889 SerializedSplitNode::Split {
890 direction,
891 ratio,
892 split_id,
893 ..
894 } => {
895 assert!(matches!(direction, SerializedSplitDirection::Vertical));
896 assert_eq!(ratio, 0.5);
897 assert_eq!(split_id, 0);
898 }
899 _ => panic!("Expected Split node"),
900 }
901 }
902
903 #[test]
904 fn test_file_state_serialization() {
905 let file_state = SerializedFileState {
906 cursor: SerializedCursor {
907 position: 1234,
908 anchor: Some(1000),
909 sticky_column: 15,
910 },
911 additional_cursors: vec![SerializedCursor {
912 position: 5000,
913 anchor: None,
914 sticky_column: 0,
915 }],
916 scroll: SerializedScroll {
917 top_byte: 500,
918 top_view_line_offset: 2,
919 left_column: 10,
920 },
921 view_mode: SerializedViewMode::Source,
922 compose_width: None,
923 plugin_state: HashMap::new(),
924 folds: Vec::new(),
925 };
926
927 let json = serde_json::to_string(&file_state).unwrap();
928 let restored: SerializedFileState = serde_json::from_str(&json).unwrap();
929
930 assert_eq!(restored.cursor.position, 1234);
931 assert_eq!(restored.cursor.anchor, Some(1000));
932 assert_eq!(restored.cursor.sticky_column, 15);
933 assert_eq!(restored.additional_cursors.len(), 1);
934 assert_eq!(restored.scroll.top_byte, 500);
935 assert_eq!(restored.scroll.left_column, 10);
936 }
937
938 #[test]
939 fn test_bookmark_serialization() {
940 let mut bookmarks = HashMap::new();
941 bookmarks.insert(
942 'a',
943 SerializedBookmark {
944 file_path: PathBuf::from("src/main.rs"),
945 position: 1234,
946 },
947 );
948 bookmarks.insert(
949 'b',
950 SerializedBookmark {
951 file_path: PathBuf::from("src/lib.rs"),
952 position: 5678,
953 },
954 );
955
956 let json = serde_json::to_string(&bookmarks).unwrap();
957 let restored: HashMap<char, SerializedBookmark> = serde_json::from_str(&json).unwrap();
958
959 assert_eq!(restored.len(), 2);
960 assert_eq!(restored.get(&'a').unwrap().position, 1234);
961 assert_eq!(
962 restored.get(&'b').unwrap().file_path,
963 PathBuf::from("src/lib.rs")
964 );
965 }
966
967 #[test]
968 fn test_search_options_serialization() {
969 let options = SearchOptions {
970 case_sensitive: true,
971 whole_word: true,
972 use_regex: false,
973 confirm_each: true,
974 };
975
976 let json = serde_json::to_string(&options).unwrap();
977 let restored: SearchOptions = serde_json::from_str(&json).unwrap();
978
979 assert!(restored.case_sensitive);
980 assert!(restored.whole_word);
981 assert!(!restored.use_regex);
982 assert!(restored.confirm_each);
983 }
984
985 #[test]
986 fn test_full_workspace_round_trip() {
987 let mut workspace = Workspace::new(PathBuf::from("/home/user/myproject"));
988
989 workspace.split_layout = SerializedSplitNode::Split {
991 direction: SerializedSplitDirection::Horizontal,
992 first: Box::new(SerializedSplitNode::Leaf {
993 file_path: Some(PathBuf::from("README.md")),
994 split_id: 1,
995 label: None,
996 }),
997 second: Box::new(SerializedSplitNode::Leaf {
998 file_path: Some(PathBuf::from("Cargo.toml")),
999 split_id: 2,
1000 label: None,
1001 }),
1002 ratio: 0.6,
1003 split_id: 0,
1004 };
1005 workspace.active_split_id = 1;
1006
1007 workspace.split_states.insert(
1009 1,
1010 SerializedSplitViewState {
1011 open_tabs: vec![
1012 SerializedTabRef::File(PathBuf::from("README.md")),
1013 SerializedTabRef::File(PathBuf::from("src/lib.rs")),
1014 ],
1015 active_tab_index: Some(0),
1016 open_files: vec![PathBuf::from("README.md"), PathBuf::from("src/lib.rs")],
1017 active_file_index: 0,
1018 file_states: HashMap::new(),
1019 tab_scroll_offset: 0,
1020 view_mode: SerializedViewMode::Source,
1021 compose_width: None,
1022 },
1023 );
1024
1025 workspace.bookmarks.insert(
1027 'm',
1028 SerializedBookmark {
1029 file_path: PathBuf::from("src/main.rs"),
1030 position: 100,
1031 },
1032 );
1033
1034 workspace.search_options.case_sensitive = true;
1036 workspace.search_options.use_regex = true;
1037
1038 let json = serde_json::to_string_pretty(&workspace).unwrap();
1040 let restored: Workspace = serde_json::from_str(&json).unwrap();
1041
1042 assert_eq!(restored.version, WORKSPACE_VERSION);
1044 assert_eq!(restored.working_dir, PathBuf::from("/home/user/myproject"));
1045 assert_eq!(restored.active_split_id, 1);
1046 assert!(restored.bookmarks.contains_key(&'m'));
1047 assert!(restored.search_options.case_sensitive);
1048 assert!(restored.search_options.use_regex);
1049
1050 let split_state = restored.split_states.get(&1).unwrap();
1052 assert_eq!(split_state.open_files.len(), 2);
1053 assert_eq!(split_state.open_files[0], PathBuf::from("README.md"));
1054 }
1055
1056 #[test]
1057 fn test_workspace_file_save_load() {
1058 use std::fs;
1059
1060 let temp_dir = std::env::temp_dir().join("fresh_workspace_test");
1062 drop(fs::remove_dir_all(&temp_dir)); fs::create_dir_all(&temp_dir).unwrap();
1064
1065 let workspace_path = temp_dir.join("test_workspace.json");
1066
1067 let mut workspace = Workspace::new(temp_dir.clone());
1069 workspace.search_options.case_sensitive = true;
1070 workspace.bookmarks.insert(
1071 'x',
1072 SerializedBookmark {
1073 file_path: PathBuf::from("test.txt"),
1074 position: 42,
1075 },
1076 );
1077
1078 let content = serde_json::to_string_pretty(&workspace).unwrap();
1080 let temp_path = workspace_path.with_extension("json.tmp");
1081 let mut file = std::fs::File::create(&temp_path).unwrap();
1082 std::io::Write::write_all(&mut file, content.as_bytes()).unwrap();
1083 file.sync_all().unwrap();
1084 std::fs::rename(&temp_path, &workspace_path).unwrap();
1085
1086 let loaded_content = fs::read_to_string(&workspace_path).unwrap();
1088 let loaded: Workspace = serde_json::from_str(&loaded_content).unwrap();
1089
1090 assert_eq!(loaded.working_dir, temp_dir);
1092 assert!(loaded.search_options.case_sensitive);
1093 assert_eq!(loaded.bookmarks.get(&'x').unwrap().position, 42);
1094
1095 drop(fs::remove_dir_all(&temp_dir));
1097 }
1098
1099 #[test]
1100 fn test_workspace_version_check() {
1101 let workspace = Workspace::new(PathBuf::from("/test"));
1102 assert_eq!(workspace.version, WORKSPACE_VERSION);
1103
1104 let mut json_value: serde_json::Value = serde_json::to_value(&workspace).unwrap();
1106 json_value["version"] = serde_json::json!(999);
1107
1108 let json = serde_json::to_string(&json_value).unwrap();
1109 let restored: Workspace = serde_json::from_str(&json).unwrap();
1110
1111 assert_eq!(restored.version, 999);
1113 }
1114
1115 #[test]
1116 fn test_empty_workspace_histories() {
1117 let histories = WorkspaceHistories::default();
1118 let json = serde_json::to_string(&histories).unwrap();
1119
1120 assert_eq!(json, "{}");
1122
1123 let restored: WorkspaceHistories = serde_json::from_str(&json).unwrap();
1125 assert!(restored.search.is_empty());
1126 assert!(restored.replace.is_empty());
1127 }
1128
1129 #[test]
1130 fn test_file_explorer_state() {
1131 let state = FileExplorerState {
1132 visible: true,
1133 width_percent: 0.25,
1134 expanded_dirs: vec![
1135 PathBuf::from("src"),
1136 PathBuf::from("src/app"),
1137 PathBuf::from("tests"),
1138 ],
1139 scroll_offset: 5,
1140 show_hidden: true,
1141 show_gitignored: false,
1142 };
1143
1144 let json = serde_json::to_string(&state).unwrap();
1145 let restored: FileExplorerState = serde_json::from_str(&json).unwrap();
1146
1147 assert!(restored.visible);
1148 assert_eq!(restored.width_percent, 0.25);
1149 assert_eq!(restored.expanded_dirs.len(), 3);
1150 assert_eq!(restored.scroll_offset, 5);
1151 assert!(restored.show_hidden);
1152 assert!(!restored.show_gitignored);
1153 }
1154}