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 #[serde(default, skip_serializing_if = "Vec::is_empty")]
89 pub read_only_files: Vec<PathBuf>,
90
91 #[serde(default, skip_serializing_if = "Vec::is_empty")]
93 pub unnamed_buffers: Vec<UnnamedBufferRef>,
94
95 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
99 pub plugin_global_state: HashMap<String, HashMap<String, serde_json::Value>>,
100
101 pub saved_at: u64,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct UnnamedBufferRef {
108 pub recovery_id: String,
110 pub display_name: String,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
116pub enum SerializedSplitNode {
117 Leaf {
118 file_path: Option<PathBuf>,
120 split_id: usize,
121 #[serde(default, skip_serializing_if = "Option::is_none")]
123 label: Option<String>,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
126 unnamed_recovery_id: Option<String>,
127 },
128 Terminal {
129 terminal_index: usize,
130 split_id: usize,
131 #[serde(default, skip_serializing_if = "Option::is_none")]
133 label: Option<String>,
134 },
135 Split {
136 direction: SerializedSplitDirection,
137 first: Box<Self>,
138 second: Box<Self>,
139 ratio: f32,
140 split_id: usize,
141 },
142}
143
144#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
145pub enum SerializedSplitDirection {
146 Horizontal,
147 Vertical,
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
152pub struct SerializedSplitViewState {
153 #[serde(default)]
155 pub open_tabs: Vec<SerializedTabRef>,
156
157 #[serde(default)]
159 pub active_tab_index: Option<usize>,
160
161 #[serde(default)]
164 pub open_files: Vec<PathBuf>,
165
166 #[serde(default)]
168 pub active_file_index: usize,
169
170 #[serde(default)]
172 pub file_states: HashMap<PathBuf, SerializedFileState>,
173
174 #[serde(default)]
176 pub tab_scroll_offset: usize,
177
178 #[serde(default)]
180 pub view_mode: SerializedViewMode,
181
182 #[serde(default)]
184 pub compose_width: Option<u16>,
185}
186
187#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct SerializedFileState {
190 pub cursor: SerializedCursor,
192
193 #[serde(default)]
195 pub additional_cursors: Vec<SerializedCursor>,
196
197 pub scroll: SerializedScroll,
199
200 #[serde(default)]
202 pub view_mode: SerializedViewMode,
203
204 #[serde(default)]
206 pub compose_width: Option<u16>,
207
208 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
210 pub plugin_state: HashMap<String, serde_json::Value>,
211
212 #[serde(default, skip_serializing_if = "Vec::is_empty")]
214 pub folds: Vec<SerializedFoldRange>,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize)]
219pub struct SerializedFoldRange {
220 pub header_line: usize,
222 pub end_line: usize,
224 #[serde(default)]
226 pub placeholder: Option<String>,
227 #[serde(default)]
236 pub header_text: Option<String>,
237}
238
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct SerializedCursor {
241 pub position: usize,
243 #[serde(default)]
245 pub anchor: Option<usize>,
246 #[serde(default)]
248 pub sticky_column: usize,
249}
250
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct SerializedScroll {
253 pub top_byte: usize,
255 #[serde(default)]
257 pub top_view_line_offset: usize,
258 #[serde(default)]
260 pub left_column: usize,
261}
262
263#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
264pub enum SerializedViewMode {
265 #[default]
266 Source,
267 #[serde(alias = "Compose")]
270 PageView,
271}
272
273#[derive(Debug, Clone, Default, Serialize, Deserialize)]
275pub struct WorkspaceConfigOverrides {
276 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub line_numbers: Option<bool>,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub relative_line_numbers: Option<bool>,
280 #[serde(default, skip_serializing_if = "Option::is_none")]
281 pub line_wrap: Option<bool>,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub syntax_highlighting: Option<bool>,
284 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub enable_inlay_hints: Option<bool>,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
287 pub mouse_enabled: Option<bool>,
288 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub menu_bar_hidden: Option<bool>,
295}
296
297#[derive(Debug, Clone, Serialize, Deserialize)]
298pub struct FileExplorerState {
299 pub visible: bool,
300 #[serde(
305 alias = "width_percent",
306 default = "crate::config::default_explorer_width_value"
307 )]
308 pub width: crate::config::ExplorerWidth,
309 #[serde(default)]
311 pub side: crate::config::FileExplorerSide,
312 #[serde(default)]
314 pub expanded_dirs: Vec<PathBuf>,
315 #[serde(default)]
317 pub scroll_offset: usize,
318 #[serde(default)]
320 pub show_hidden: bool,
321 #[serde(default)]
323 pub show_gitignored: bool,
324}
325
326impl Default for FileExplorerState {
327 fn default() -> Self {
328 Self {
329 visible: false,
330 width: crate::config::default_explorer_width_value(),
331 side: crate::config::FileExplorerSide::Left,
332 expanded_dirs: Vec::new(),
333 scroll_offset: 0,
334 show_hidden: false,
335 show_gitignored: false,
336 }
337 }
338}
339
340#[derive(Debug, Clone, Default, Serialize, Deserialize)]
342pub struct WorkspaceHistories {
343 #[serde(default, skip_serializing_if = "Vec::is_empty")]
344 pub search: Vec<String>,
345 #[serde(default, skip_serializing_if = "Vec::is_empty")]
346 pub replace: Vec<String>,
347 #[serde(default, skip_serializing_if = "Vec::is_empty")]
348 pub command_palette: Vec<String>,
349 #[serde(default, skip_serializing_if = "Vec::is_empty")]
350 pub goto_line: Vec<String>,
351 #[serde(default, skip_serializing_if = "Vec::is_empty")]
352 pub open_file: Vec<String>,
353}
354
355#[derive(Debug, Clone, Default, Serialize, Deserialize)]
357pub struct SearchOptions {
358 #[serde(default)]
359 pub case_sensitive: bool,
360 #[serde(default)]
361 pub whole_word: bool,
362 #[serde(default)]
363 pub use_regex: bool,
364 #[serde(default)]
365 pub confirm_each: bool,
366}
367
368#[derive(Debug, Clone, Serialize, Deserialize)]
370pub struct SerializedBookmark {
371 pub file_path: PathBuf,
373 pub position: usize,
375}
376
377#[derive(Debug, Clone, Serialize, Deserialize)]
379pub enum SerializedTabRef {
380 File(PathBuf),
381 Terminal(usize),
382 Unnamed(String),
384}
385
386#[derive(Debug, Clone, Serialize, Deserialize)]
388pub struct SerializedTerminalWorkspace {
389 pub terminal_index: usize,
390 pub cwd: Option<PathBuf>,
391 pub shell: String,
392 pub cols: u16,
393 pub rows: u16,
394 pub log_path: PathBuf,
395 pub backing_path: PathBuf,
396}
397
398#[derive(Debug, Clone, Serialize, Deserialize)]
409pub struct PersistedFileState {
410 pub version: u32,
412
413 pub state: SerializedFileState,
415
416 pub saved_at: u64,
418}
419
420impl PersistedFileState {
421 fn new(state: SerializedFileState) -> Self {
422 Self {
423 version: FILE_WORKSPACE_VERSION,
424 state,
425 saved_at: SystemTime::now()
426 .duration_since(UNIX_EPOCH)
427 .unwrap_or_default()
428 .as_secs(),
429 }
430 }
431}
432
433pub struct PersistedFileWorkspace;
445
446impl PersistedFileWorkspace {
447 fn states_dir() -> io::Result<PathBuf> {
449 Ok(get_data_dir()?.join("file_states"))
450 }
451
452 fn state_file_path(source_path: &Path) -> io::Result<PathBuf> {
454 let canonical = source_path
455 .canonicalize()
456 .unwrap_or_else(|_| source_path.to_path_buf());
457 let filename = format!("{}.json", encode_path_for_filename(&canonical));
458 Ok(Self::states_dir()?.join(filename))
459 }
460
461 pub fn load(path: &Path) -> Option<SerializedFileState> {
463 let state_path = match Self::state_file_path(path) {
464 Ok(p) => p,
465 Err(_) => return None,
466 };
467
468 if !state_path.exists() {
469 return None;
470 }
471
472 let content = match std::fs::read_to_string(&state_path) {
473 Ok(c) => c,
474 Err(_) => return None,
475 };
476
477 let persisted: PersistedFileState = match serde_json::from_str(&content) {
478 Ok(p) => p,
479 Err(_) => return None,
480 };
481
482 if persisted.version > FILE_WORKSPACE_VERSION {
484 return None;
485 }
486
487 Some(persisted.state)
488 }
489
490 pub fn save(path: &Path, state: SerializedFileState) {
492 let state_path = match Self::state_file_path(path) {
493 Ok(p) => p,
494 Err(e) => {
495 tracing::warn!("Failed to get state path for {:?}: {}", path, e);
496 return;
497 }
498 };
499
500 if let Some(parent) = state_path.parent() {
502 if let Err(e) = std::fs::create_dir_all(parent) {
503 tracing::warn!("Failed to create state dir: {}", e);
504 return;
505 }
506 }
507
508 let persisted = PersistedFileState::new(state);
509 let content = match serde_json::to_string_pretty(&persisted) {
510 Ok(c) => c,
511 Err(e) => {
512 tracing::warn!("Failed to serialize file state: {}", e);
513 return;
514 }
515 };
516
517 let temp_path = state_path.with_extension("json.tmp");
519
520 let write_result = (|| -> io::Result<()> {
521 let mut file = std::fs::File::create(&temp_path)?;
522 file.write_all(content.as_bytes())?;
523 file.sync_all()?;
524 std::fs::rename(&temp_path, &state_path)?;
525 Ok(())
526 })();
527
528 if let Err(e) = write_result {
529 tracing::warn!("Failed to save file state for {:?}: {}", path, e);
530 } else {
531 tracing::trace!("File state saved for {:?}", path);
532 }
533 }
534}
535
536pub fn get_workspaces_dir() -> io::Result<PathBuf> {
542 Ok(get_data_dir()?.join("workspaces"))
543}
544
545pub fn encode_path_for_filename(path: &Path) -> String {
553 let path_str = path.to_string_lossy();
554 let mut result = String::with_capacity(path_str.len() * 2);
555
556 for c in path_str.chars() {
557 match c {
558 '/' | '\\' => result.push('_'),
560 c if c.is_ascii_alphanumeric() => result.push(c),
562 '-' | '.' => result.push(c),
563 '_' => result.push_str("%5F"),
565 c => {
567 for byte in c.to_string().as_bytes() {
568 result.push_str(&format!("%{:02X}", byte));
569 }
570 }
571 }
572 }
573
574 let result = result.trim_start_matches('_').to_string();
576
577 let mut final_result = String::with_capacity(result.len());
579 let mut last_was_underscore = false;
580 for c in result.chars() {
581 if c == '_' {
582 if !last_was_underscore {
583 final_result.push(c);
584 }
585 last_was_underscore = true;
586 } else {
587 final_result.push(c);
588 last_was_underscore = false;
589 }
590 }
591
592 if final_result.is_empty() {
593 final_result = "root".to_string();
594 }
595
596 final_result
597}
598
599#[allow(dead_code)]
601pub fn decode_filename_to_path(encoded: &str) -> Option<PathBuf> {
602 if encoded == "root" {
603 return Some(PathBuf::from("/"));
604 }
605
606 let mut result = String::with_capacity(encoded.len() + 1);
607 result.push('/');
609
610 let mut chars = encoded.chars().peekable();
611
612 while let Some(c) = chars.next() {
613 if c == '%' {
614 let hex: String = chars.by_ref().take(2).collect();
616 if hex.len() == 2 {
617 if let Ok(byte) = u8::from_str_radix(&hex, 16) {
618 result.push(byte as char);
619 }
620 }
621 } else if c == '_' {
622 result.push('/');
623 } else {
624 result.push(c);
625 }
626 }
627
628 Some(PathBuf::from(result))
629}
630
631pub fn get_workspace_path(working_dir: &Path) -> io::Result<PathBuf> {
633 let canonical = working_dir
634 .canonicalize()
635 .unwrap_or_else(|_| working_dir.to_path_buf());
636 let filename = format!("{}.json", encode_path_for_filename(&canonical));
637 Ok(get_workspaces_dir()?.join(filename))
638}
639
640pub fn get_session_workspaces_dir() -> io::Result<PathBuf> {
642 Ok(get_data_dir()?.join("session-workspaces"))
643}
644
645pub fn get_session_workspace_path(session_name: &str) -> io::Result<PathBuf> {
647 let dir = get_session_workspaces_dir()?;
648 std::fs::create_dir_all(&dir)?;
649 let safe_name: String = session_name
651 .chars()
652 .map(|c| {
653 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
654 c
655 } else {
656 '_'
657 }
658 })
659 .collect();
660 Ok(dir.join(format!("{}.json", safe_name)))
661}
662
663#[derive(Debug)]
665pub enum WorkspaceError {
666 Io(anyhow::Error),
667 Json(serde_json::Error),
668 WorkdirMismatch { expected: PathBuf, found: PathBuf },
669 VersionTooNew { version: u32, max_supported: u32 },
670}
671
672impl std::fmt::Display for WorkspaceError {
673 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
674 match self {
675 Self::Io(e) => write!(f, "Workspace error: {}", e),
676 Self::Json(e) => write!(f, "JSON error: {}", e),
677 Self::WorkdirMismatch { expected, found } => {
678 write!(
679 f,
680 "Working directory mismatch: expected {:?}, found {:?}",
681 expected, found
682 )
683 }
684 WorkspaceError::VersionTooNew {
685 version,
686 max_supported,
687 } => {
688 write!(
689 f,
690 "Workspace version {} is newer than supported (max: {})",
691 version, max_supported
692 )
693 }
694 }
695 }
696}
697
698impl std::error::Error for WorkspaceError {
699 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
700 match self {
701 Self::Io(e) => e.source(),
702 Self::Json(e) => Some(e),
703 _ => None,
704 }
705 }
706}
707
708impl From<io::Error> for WorkspaceError {
709 fn from(e: io::Error) -> Self {
710 WorkspaceError::Io(e.into())
711 }
712}
713
714impl From<anyhow::Error> for WorkspaceError {
715 fn from(e: anyhow::Error) -> Self {
716 WorkspaceError::Io(e)
717 }
718}
719
720impl From<serde_json::Error> for WorkspaceError {
721 fn from(e: serde_json::Error) -> Self {
722 WorkspaceError::Json(e)
723 }
724}
725
726impl Workspace {
727 pub fn load(working_dir: &Path) -> Result<Option<Workspace>, WorkspaceError> {
729 let path = get_workspace_path(working_dir)?;
730 tracing::debug!("Looking for workspace at {:?}", path);
731
732 if !path.exists() {
733 tracing::debug!("Workspace file does not exist");
734 return Ok(None);
735 }
736
737 tracing::debug!("Loading workspace from {:?}", path);
738 let content = std::fs::read_to_string(&path)?;
739 let workspace: Workspace = serde_json::from_str(&content)?;
740
741 tracing::debug!(
742 "Loaded workspace: version={}, split_states={}, active_split={}",
743 workspace.version,
744 workspace.split_states.len(),
745 workspace.active_split_id
746 );
747
748 let expected = working_dir
750 .canonicalize()
751 .unwrap_or_else(|_| working_dir.to_path_buf());
752 let found = workspace
753 .working_dir
754 .canonicalize()
755 .unwrap_or_else(|_| workspace.working_dir.clone());
756
757 if expected != found {
758 tracing::warn!(
759 "Workspace working_dir mismatch: expected {:?}, found {:?}",
760 expected,
761 found
762 );
763 return Err(WorkspaceError::WorkdirMismatch { expected, found });
764 }
765
766 if workspace.version > WORKSPACE_VERSION {
768 tracing::warn!(
769 "Workspace version {} is newer than supported {}",
770 workspace.version,
771 WORKSPACE_VERSION
772 );
773 return Err(WorkspaceError::VersionTooNew {
774 version: workspace.version,
775 max_supported: WORKSPACE_VERSION,
776 });
777 }
778
779 Ok(Some(workspace))
780 }
781
782 pub fn save(&self) -> Result<(), WorkspaceError> {
789 let path = get_workspace_path(&self.working_dir)?;
790 tracing::debug!("Saving workspace to {:?}", path);
791
792 if let Some(parent) = path.parent() {
794 std::fs::create_dir_all(parent)?;
795 }
796
797 let content = serde_json::to_string_pretty(self)?;
799 tracing::trace!("Workspace JSON size: {} bytes", content.len());
800
801 let temp_path = path.with_extension("json.tmp");
803
804 {
806 let mut file = std::fs::File::create(&temp_path)?;
807 file.write_all(content.as_bytes())?;
808 file.sync_all()?; }
810
811 std::fs::rename(&temp_path, &path)?;
813 tracing::info!("Workspace saved to {:?}", path);
814
815 Ok(())
816 }
817
818 pub fn load_session(
820 session_name: &str,
821 working_dir: &Path,
822 ) -> Result<Option<Workspace>, WorkspaceError> {
823 let path = get_session_workspace_path(session_name)?;
824 tracing::debug!("Looking for session workspace at {:?}", path);
825
826 if !path.exists() {
827 return Ok(None);
828 }
829
830 let content = std::fs::read_to_string(&path)?;
831 let workspace: Workspace = serde_json::from_str(&content)?;
832
833 if workspace.version > WORKSPACE_VERSION {
836 return Err(WorkspaceError::VersionTooNew {
837 version: workspace.version,
838 max_supported: WORKSPACE_VERSION,
839 });
840 }
841
842 let found = workspace
844 .working_dir
845 .canonicalize()
846 .unwrap_or_else(|_| workspace.working_dir.clone());
847 let expected = working_dir
848 .canonicalize()
849 .unwrap_or_else(|_| working_dir.to_path_buf());
850 if expected != found {
851 tracing::info!(
852 "Session '{}' workspace was saved from {:?}, now loading from {:?}",
853 session_name,
854 found,
855 expected
856 );
857 }
858
859 Ok(Some(workspace))
860 }
861
862 pub fn save_session(&self, session_name: &str) -> Result<(), WorkspaceError> {
864 let path = get_session_workspace_path(session_name)?;
865 tracing::debug!("Saving session workspace to {:?}", path);
866
867 if let Some(parent) = path.parent() {
868 std::fs::create_dir_all(parent)?;
869 }
870
871 let content = serde_json::to_string_pretty(self)?;
872 let temp_path = path.with_extension("json.tmp");
873 {
874 let mut file = std::fs::File::create(&temp_path)?;
875 file.write_all(content.as_bytes())?;
876 file.sync_all()?;
877 }
878 std::fs::rename(&temp_path, &path)?;
879 tracing::info!("Session workspace saved to {:?}", path);
880 Ok(())
881 }
882
883 pub fn delete(working_dir: &Path) -> Result<(), WorkspaceError> {
885 let path = get_workspace_path(working_dir)?;
886 if path.exists() {
887 std::fs::remove_file(path)?;
888 }
889 Ok(())
890 }
891
892 pub fn new(working_dir: PathBuf) -> Self {
894 Self {
895 version: WORKSPACE_VERSION,
896 working_dir,
897 split_layout: SerializedSplitNode::Leaf {
898 file_path: None,
899 split_id: 0,
900 label: None,
901 unnamed_recovery_id: None,
902 },
903 active_split_id: 0,
904 split_states: HashMap::new(),
905 config_overrides: WorkspaceConfigOverrides::default(),
906 file_explorer: FileExplorerState::default(),
907 histories: WorkspaceHistories::default(),
908 search_options: SearchOptions::default(),
909 bookmarks: HashMap::new(),
910 terminals: Vec::new(),
911 external_files: Vec::new(),
912 read_only_files: Vec::new(),
913 unnamed_buffers: Vec::new(),
914 plugin_global_state: HashMap::new(),
915 saved_at: SystemTime::now()
916 .duration_since(UNIX_EPOCH)
917 .unwrap_or_default()
918 .as_secs(),
919 }
920 }
921
922 pub fn touch(&mut self) {
924 self.saved_at = SystemTime::now()
925 .duration_since(UNIX_EPOCH)
926 .unwrap_or_default()
927 .as_secs();
928 }
929}
930
931#[cfg(test)]
932mod tests {
933 use super::*;
934
935 #[test]
936 fn test_workspace_path_percent_encoding() {
937 let encoded = encode_path_for_filename(Path::new("/home/user/project"));
939 assert_eq!(encoded, "home_user_project");
940 assert!(!encoded.contains('/')); let decoded = decode_filename_to_path(&encoded).unwrap();
944 assert_eq!(decoded, PathBuf::from("/home/user/project"));
945
946 let path1 = get_workspace_path(Path::new("/home/user/project")).unwrap();
948 let path2 = get_workspace_path(Path::new("/home/user/other")).unwrap();
949 assert_ne!(path1, path2);
950
951 let path1_again = get_workspace_path(Path::new("/home/user/project")).unwrap();
953 assert_eq!(path1, path1_again);
954
955 let filename = path1.file_name().unwrap().to_str().unwrap();
957 assert!(filename.ends_with(".json"));
958 assert!(filename.starts_with("home_user_project"));
959 }
960
961 #[test]
962 fn test_percent_encoding_edge_cases() {
963 let encoded = encode_path_for_filename(Path::new("/home/user/my-project"));
965 assert_eq!(encoded, "home_user_my-project");
966
967 let encoded = encode_path_for_filename(Path::new("/home/user/my project"));
969 assert_eq!(encoded, "home_user_my%20project");
970 let decoded = decode_filename_to_path(&encoded).unwrap();
971 assert_eq!(decoded, PathBuf::from("/home/user/my project"));
972
973 let encoded = encode_path_for_filename(Path::new("/home/user/my_project"));
975 assert_eq!(encoded, "home_user_my%5Fproject");
976 let decoded = decode_filename_to_path(&encoded).unwrap();
977 assert_eq!(decoded, PathBuf::from("/home/user/my_project"));
978
979 let encoded = encode_path_for_filename(Path::new("/"));
981 assert_eq!(encoded, "root");
982 }
983
984 #[test]
985 fn test_workspace_serialization() {
986 let workspace = Workspace::new(PathBuf::from("/home/user/test"));
987 let json = serde_json::to_string(&workspace).unwrap();
988 let restored: Workspace = serde_json::from_str(&json).unwrap();
989
990 assert_eq!(workspace.version, restored.version);
991 assert_eq!(workspace.working_dir, restored.working_dir);
992 }
993
994 #[test]
995 fn test_workspace_config_overrides_skip_none() {
996 let overrides = WorkspaceConfigOverrides::default();
997 let json = serde_json::to_string(&overrides).unwrap();
998
999 assert_eq!(json, "{}");
1001 }
1002
1003 #[test]
1004 fn test_workspace_config_overrides_with_values() {
1005 let overrides = WorkspaceConfigOverrides {
1006 line_wrap: Some(false),
1007 ..Default::default()
1008 };
1009 let json = serde_json::to_string(&overrides).unwrap();
1010
1011 assert!(json.contains("line_wrap"));
1012 assert!(!json.contains("line_numbers")); }
1014
1015 #[test]
1016 fn test_split_layout_serialization() {
1017 let layout = SerializedSplitNode::Split {
1019 direction: SerializedSplitDirection::Vertical,
1020 first: Box::new(SerializedSplitNode::Leaf {
1021 file_path: Some(PathBuf::from("src/main.rs")),
1022 split_id: 1,
1023 label: None,
1024 unnamed_recovery_id: None,
1025 }),
1026 second: Box::new(SerializedSplitNode::Leaf {
1027 file_path: Some(PathBuf::from("src/lib.rs")),
1028 split_id: 2,
1029 label: None,
1030 unnamed_recovery_id: None,
1031 }),
1032 ratio: 0.5,
1033 split_id: 0,
1034 };
1035
1036 let json = serde_json::to_string(&layout).unwrap();
1037 let restored: SerializedSplitNode = serde_json::from_str(&json).unwrap();
1038
1039 match restored {
1041 SerializedSplitNode::Split {
1042 direction,
1043 ratio,
1044 split_id,
1045 ..
1046 } => {
1047 assert!(matches!(direction, SerializedSplitDirection::Vertical));
1048 assert_eq!(ratio, 0.5);
1049 assert_eq!(split_id, 0);
1050 }
1051 _ => panic!("Expected Split node"),
1052 }
1053 }
1054
1055 #[test]
1056 fn test_file_state_serialization() {
1057 let file_state = SerializedFileState {
1058 cursor: SerializedCursor {
1059 position: 1234,
1060 anchor: Some(1000),
1061 sticky_column: 15,
1062 },
1063 additional_cursors: vec![SerializedCursor {
1064 position: 5000,
1065 anchor: None,
1066 sticky_column: 0,
1067 }],
1068 scroll: SerializedScroll {
1069 top_byte: 500,
1070 top_view_line_offset: 2,
1071 left_column: 10,
1072 },
1073 view_mode: SerializedViewMode::Source,
1074 compose_width: None,
1075 plugin_state: HashMap::new(),
1076 folds: Vec::new(),
1077 };
1078
1079 let json = serde_json::to_string(&file_state).unwrap();
1080 let restored: SerializedFileState = serde_json::from_str(&json).unwrap();
1081
1082 assert_eq!(restored.cursor.position, 1234);
1083 assert_eq!(restored.cursor.anchor, Some(1000));
1084 assert_eq!(restored.cursor.sticky_column, 15);
1085 assert_eq!(restored.additional_cursors.len(), 1);
1086 assert_eq!(restored.scroll.top_byte, 500);
1087 assert_eq!(restored.scroll.left_column, 10);
1088 }
1089
1090 #[test]
1091 fn test_bookmark_serialization() {
1092 let mut bookmarks = HashMap::new();
1093 bookmarks.insert(
1094 'a',
1095 SerializedBookmark {
1096 file_path: PathBuf::from("src/main.rs"),
1097 position: 1234,
1098 },
1099 );
1100 bookmarks.insert(
1101 'b',
1102 SerializedBookmark {
1103 file_path: PathBuf::from("src/lib.rs"),
1104 position: 5678,
1105 },
1106 );
1107
1108 let json = serde_json::to_string(&bookmarks).unwrap();
1109 let restored: HashMap<char, SerializedBookmark> = serde_json::from_str(&json).unwrap();
1110
1111 assert_eq!(restored.len(), 2);
1112 assert_eq!(restored.get(&'a').unwrap().position, 1234);
1113 assert_eq!(
1114 restored.get(&'b').unwrap().file_path,
1115 PathBuf::from("src/lib.rs")
1116 );
1117 }
1118
1119 #[test]
1120 fn test_search_options_serialization() {
1121 let options = SearchOptions {
1122 case_sensitive: true,
1123 whole_word: true,
1124 use_regex: false,
1125 confirm_each: true,
1126 };
1127
1128 let json = serde_json::to_string(&options).unwrap();
1129 let restored: SearchOptions = serde_json::from_str(&json).unwrap();
1130
1131 assert!(restored.case_sensitive);
1132 assert!(restored.whole_word);
1133 assert!(!restored.use_regex);
1134 assert!(restored.confirm_each);
1135 }
1136
1137 #[test]
1138 fn test_full_workspace_round_trip() {
1139 let mut workspace = Workspace::new(PathBuf::from("/home/user/myproject"));
1140
1141 workspace.split_layout = SerializedSplitNode::Split {
1143 direction: SerializedSplitDirection::Horizontal,
1144 first: Box::new(SerializedSplitNode::Leaf {
1145 file_path: Some(PathBuf::from("README.md")),
1146 split_id: 1,
1147 label: None,
1148 unnamed_recovery_id: None,
1149 }),
1150 second: Box::new(SerializedSplitNode::Leaf {
1151 file_path: Some(PathBuf::from("Cargo.toml")),
1152 split_id: 2,
1153 label: None,
1154 unnamed_recovery_id: None,
1155 }),
1156 ratio: 0.6,
1157 split_id: 0,
1158 };
1159 workspace.active_split_id = 1;
1160
1161 workspace.split_states.insert(
1163 1,
1164 SerializedSplitViewState {
1165 open_tabs: vec![
1166 SerializedTabRef::File(PathBuf::from("README.md")),
1167 SerializedTabRef::File(PathBuf::from("src/lib.rs")),
1168 ],
1169 active_tab_index: Some(0),
1170 open_files: vec![PathBuf::from("README.md"), PathBuf::from("src/lib.rs")],
1171 active_file_index: 0,
1172 file_states: HashMap::new(),
1173 tab_scroll_offset: 0,
1174 view_mode: SerializedViewMode::Source,
1175 compose_width: None,
1176 },
1177 );
1178
1179 workspace.bookmarks.insert(
1181 'm',
1182 SerializedBookmark {
1183 file_path: PathBuf::from("src/main.rs"),
1184 position: 100,
1185 },
1186 );
1187
1188 workspace.search_options.case_sensitive = true;
1190 workspace.search_options.use_regex = true;
1191
1192 let json = serde_json::to_string_pretty(&workspace).unwrap();
1194 let restored: Workspace = serde_json::from_str(&json).unwrap();
1195
1196 assert_eq!(restored.version, WORKSPACE_VERSION);
1198 assert_eq!(restored.working_dir, PathBuf::from("/home/user/myproject"));
1199 assert_eq!(restored.active_split_id, 1);
1200 assert!(restored.bookmarks.contains_key(&'m'));
1201 assert!(restored.search_options.case_sensitive);
1202 assert!(restored.search_options.use_regex);
1203
1204 let split_state = restored.split_states.get(&1).unwrap();
1206 assert_eq!(split_state.open_files.len(), 2);
1207 assert_eq!(split_state.open_files[0], PathBuf::from("README.md"));
1208 }
1209
1210 #[test]
1211 fn test_workspace_file_save_load() {
1212 use std::fs;
1213
1214 let temp_dir = std::env::temp_dir().join("fresh_workspace_test");
1216 drop(fs::remove_dir_all(&temp_dir)); fs::create_dir_all(&temp_dir).unwrap();
1218
1219 let workspace_path = temp_dir.join("test_workspace.json");
1220
1221 let mut workspace = Workspace::new(temp_dir.clone());
1223 workspace.search_options.case_sensitive = true;
1224 workspace.bookmarks.insert(
1225 'x',
1226 SerializedBookmark {
1227 file_path: PathBuf::from("test.txt"),
1228 position: 42,
1229 },
1230 );
1231
1232 let content = serde_json::to_string_pretty(&workspace).unwrap();
1234 let temp_path = workspace_path.with_extension("json.tmp");
1235 let mut file = std::fs::File::create(&temp_path).unwrap();
1236 std::io::Write::write_all(&mut file, content.as_bytes()).unwrap();
1237 file.sync_all().unwrap();
1238 std::fs::rename(&temp_path, &workspace_path).unwrap();
1239
1240 let loaded_content = fs::read_to_string(&workspace_path).unwrap();
1242 let loaded: Workspace = serde_json::from_str(&loaded_content).unwrap();
1243
1244 assert_eq!(loaded.working_dir, temp_dir);
1246 assert!(loaded.search_options.case_sensitive);
1247 assert_eq!(loaded.bookmarks.get(&'x').unwrap().position, 42);
1248
1249 drop(fs::remove_dir_all(&temp_dir));
1251 }
1252
1253 #[test]
1254 fn test_workspace_version_check() {
1255 let workspace = Workspace::new(PathBuf::from("/test"));
1256 assert_eq!(workspace.version, WORKSPACE_VERSION);
1257
1258 let mut json_value: serde_json::Value = serde_json::to_value(&workspace).unwrap();
1260 json_value["version"] = serde_json::json!(999);
1261
1262 let json = serde_json::to_string(&json_value).unwrap();
1263 let restored: Workspace = serde_json::from_str(&json).unwrap();
1264
1265 assert_eq!(restored.version, 999);
1267 }
1268
1269 #[test]
1270 fn test_empty_workspace_histories() {
1271 let histories = WorkspaceHistories::default();
1272 let json = serde_json::to_string(&histories).unwrap();
1273
1274 assert_eq!(json, "{}");
1276
1277 let restored: WorkspaceHistories = serde_json::from_str(&json).unwrap();
1279 assert!(restored.search.is_empty());
1280 assert!(restored.replace.is_empty());
1281 }
1282
1283 #[test]
1284 fn test_file_explorer_state_percent_round_trip() {
1285 let state = FileExplorerState {
1286 visible: true,
1287 width: crate::config::ExplorerWidth::Percent(25),
1288 side: crate::config::FileExplorerSide::Left,
1289 expanded_dirs: vec![
1290 PathBuf::from("src"),
1291 PathBuf::from("src/app"),
1292 PathBuf::from("tests"),
1293 ],
1294 scroll_offset: 5,
1295 show_hidden: true,
1296 show_gitignored: false,
1297 };
1298
1299 let json = serde_json::to_string(&state).unwrap();
1300 let restored: FileExplorerState = serde_json::from_str(&json).unwrap();
1301
1302 assert!(restored.visible);
1303 assert_eq!(restored.width, crate::config::ExplorerWidth::Percent(25));
1304 assert_eq!(restored.expanded_dirs.len(), 3);
1305 assert_eq!(restored.scroll_offset, 5);
1306 assert!(restored.show_hidden);
1307 assert!(!restored.show_gitignored);
1308 }
1309
1310 #[test]
1311 fn test_file_explorer_state_columns_round_trip() {
1312 let state = FileExplorerState {
1313 visible: true,
1314 width: crate::config::ExplorerWidth::Columns(42),
1315 side: crate::config::FileExplorerSide::Left,
1316 expanded_dirs: vec![],
1317 scroll_offset: 0,
1318 show_hidden: false,
1319 show_gitignored: false,
1320 };
1321 let json = serde_json::to_string(&state).unwrap();
1322 let restored: FileExplorerState = serde_json::from_str(&json).unwrap();
1323 assert_eq!(restored.width, crate::config::ExplorerWidth::Columns(42));
1324 }
1325
1326 #[test]
1331 fn test_file_explorer_state_legacy_width_percent_alias() {
1332 let json = r#"{
1333 "visible": true,
1334 "width_percent": 0.3,
1335 "expanded_dirs": [],
1336 "scroll_offset": 0,
1337 "show_hidden": false,
1338 "show_gitignored": false
1339 }"#;
1340 let restored: FileExplorerState = serde_json::from_str(json).unwrap();
1341 assert_eq!(restored.width, crate::config::ExplorerWidth::Percent(30));
1342 }
1343}