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")]
289 pub menu_bar_hidden: Option<bool>,
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize)]
293pub struct FileExplorerState {
294 pub visible: bool,
295 #[serde(default)]
296 pub width_percent: f32,
297 #[serde(default)]
299 pub expanded_dirs: Vec<PathBuf>,
300 #[serde(default)]
302 pub scroll_offset: usize,
303 #[serde(default)]
305 pub show_hidden: bool,
306 #[serde(default)]
308 pub show_gitignored: bool,
309}
310
311impl Default for FileExplorerState {
312 fn default() -> Self {
313 Self {
314 visible: false,
315 width_percent: 0.3,
316 expanded_dirs: Vec::new(),
317 scroll_offset: 0,
318 show_hidden: false,
319 show_gitignored: false,
320 }
321 }
322}
323
324#[derive(Debug, Clone, Default, Serialize, Deserialize)]
326pub struct WorkspaceHistories {
327 #[serde(default, skip_serializing_if = "Vec::is_empty")]
328 pub search: Vec<String>,
329 #[serde(default, skip_serializing_if = "Vec::is_empty")]
330 pub replace: Vec<String>,
331 #[serde(default, skip_serializing_if = "Vec::is_empty")]
332 pub command_palette: Vec<String>,
333 #[serde(default, skip_serializing_if = "Vec::is_empty")]
334 pub goto_line: Vec<String>,
335 #[serde(default, skip_serializing_if = "Vec::is_empty")]
336 pub open_file: Vec<String>,
337}
338
339#[derive(Debug, Clone, Default, Serialize, Deserialize)]
341pub struct SearchOptions {
342 #[serde(default)]
343 pub case_sensitive: bool,
344 #[serde(default)]
345 pub whole_word: bool,
346 #[serde(default)]
347 pub use_regex: bool,
348 #[serde(default)]
349 pub confirm_each: bool,
350}
351
352#[derive(Debug, Clone, Serialize, Deserialize)]
354pub struct SerializedBookmark {
355 pub file_path: PathBuf,
357 pub position: usize,
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize)]
363pub enum SerializedTabRef {
364 File(PathBuf),
365 Terminal(usize),
366 Unnamed(String),
368}
369
370#[derive(Debug, Clone, Serialize, Deserialize)]
372pub struct SerializedTerminalWorkspace {
373 pub terminal_index: usize,
374 pub cwd: Option<PathBuf>,
375 pub shell: String,
376 pub cols: u16,
377 pub rows: u16,
378 pub log_path: PathBuf,
379 pub backing_path: PathBuf,
380}
381
382#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct PersistedFileState {
394 pub version: u32,
396
397 pub state: SerializedFileState,
399
400 pub saved_at: u64,
402}
403
404impl PersistedFileState {
405 fn new(state: SerializedFileState) -> Self {
406 Self {
407 version: FILE_WORKSPACE_VERSION,
408 state,
409 saved_at: SystemTime::now()
410 .duration_since(UNIX_EPOCH)
411 .unwrap_or_default()
412 .as_secs(),
413 }
414 }
415}
416
417pub struct PersistedFileWorkspace;
429
430impl PersistedFileWorkspace {
431 fn states_dir() -> io::Result<PathBuf> {
433 Ok(get_data_dir()?.join("file_states"))
434 }
435
436 fn state_file_path(source_path: &Path) -> io::Result<PathBuf> {
438 let canonical = source_path
439 .canonicalize()
440 .unwrap_or_else(|_| source_path.to_path_buf());
441 let filename = format!("{}.json", encode_path_for_filename(&canonical));
442 Ok(Self::states_dir()?.join(filename))
443 }
444
445 pub fn load(path: &Path) -> Option<SerializedFileState> {
447 let state_path = match Self::state_file_path(path) {
448 Ok(p) => p,
449 Err(_) => return None,
450 };
451
452 if !state_path.exists() {
453 return None;
454 }
455
456 let content = match std::fs::read_to_string(&state_path) {
457 Ok(c) => c,
458 Err(_) => return None,
459 };
460
461 let persisted: PersistedFileState = match serde_json::from_str(&content) {
462 Ok(p) => p,
463 Err(_) => return None,
464 };
465
466 if persisted.version > FILE_WORKSPACE_VERSION {
468 return None;
469 }
470
471 Some(persisted.state)
472 }
473
474 pub fn save(path: &Path, state: SerializedFileState) {
476 let state_path = match Self::state_file_path(path) {
477 Ok(p) => p,
478 Err(e) => {
479 tracing::warn!("Failed to get state path for {:?}: {}", path, e);
480 return;
481 }
482 };
483
484 if let Some(parent) = state_path.parent() {
486 if let Err(e) = std::fs::create_dir_all(parent) {
487 tracing::warn!("Failed to create state dir: {}", e);
488 return;
489 }
490 }
491
492 let persisted = PersistedFileState::new(state);
493 let content = match serde_json::to_string_pretty(&persisted) {
494 Ok(c) => c,
495 Err(e) => {
496 tracing::warn!("Failed to serialize file state: {}", e);
497 return;
498 }
499 };
500
501 let temp_path = state_path.with_extension("json.tmp");
503
504 let write_result = (|| -> io::Result<()> {
505 let mut file = std::fs::File::create(&temp_path)?;
506 file.write_all(content.as_bytes())?;
507 file.sync_all()?;
508 std::fs::rename(&temp_path, &state_path)?;
509 Ok(())
510 })();
511
512 if let Err(e) = write_result {
513 tracing::warn!("Failed to save file state for {:?}: {}", path, e);
514 } else {
515 tracing::trace!("File state saved for {:?}", path);
516 }
517 }
518}
519
520pub fn get_workspaces_dir() -> io::Result<PathBuf> {
526 Ok(get_data_dir()?.join("workspaces"))
527}
528
529pub fn encode_path_for_filename(path: &Path) -> String {
537 let path_str = path.to_string_lossy();
538 let mut result = String::with_capacity(path_str.len() * 2);
539
540 for c in path_str.chars() {
541 match c {
542 '/' | '\\' => result.push('_'),
544 c if c.is_ascii_alphanumeric() => result.push(c),
546 '-' | '.' => result.push(c),
547 '_' => result.push_str("%5F"),
549 c => {
551 for byte in c.to_string().as_bytes() {
552 result.push_str(&format!("%{:02X}", byte));
553 }
554 }
555 }
556 }
557
558 let result = result.trim_start_matches('_').to_string();
560
561 let mut final_result = String::with_capacity(result.len());
563 let mut last_was_underscore = false;
564 for c in result.chars() {
565 if c == '_' {
566 if !last_was_underscore {
567 final_result.push(c);
568 }
569 last_was_underscore = true;
570 } else {
571 final_result.push(c);
572 last_was_underscore = false;
573 }
574 }
575
576 if final_result.is_empty() {
577 final_result = "root".to_string();
578 }
579
580 final_result
581}
582
583#[allow(dead_code)]
585pub fn decode_filename_to_path(encoded: &str) -> Option<PathBuf> {
586 if encoded == "root" {
587 return Some(PathBuf::from("/"));
588 }
589
590 let mut result = String::with_capacity(encoded.len() + 1);
591 result.push('/');
593
594 let mut chars = encoded.chars().peekable();
595
596 while let Some(c) = chars.next() {
597 if c == '%' {
598 let hex: String = chars.by_ref().take(2).collect();
600 if hex.len() == 2 {
601 if let Ok(byte) = u8::from_str_radix(&hex, 16) {
602 result.push(byte as char);
603 }
604 }
605 } else if c == '_' {
606 result.push('/');
607 } else {
608 result.push(c);
609 }
610 }
611
612 Some(PathBuf::from(result))
613}
614
615pub fn get_workspace_path(working_dir: &Path) -> io::Result<PathBuf> {
617 let canonical = working_dir
618 .canonicalize()
619 .unwrap_or_else(|_| working_dir.to_path_buf());
620 let filename = format!("{}.json", encode_path_for_filename(&canonical));
621 Ok(get_workspaces_dir()?.join(filename))
622}
623
624pub fn get_session_workspaces_dir() -> io::Result<PathBuf> {
626 Ok(get_data_dir()?.join("session-workspaces"))
627}
628
629pub fn get_session_workspace_path(session_name: &str) -> io::Result<PathBuf> {
631 let dir = get_session_workspaces_dir()?;
632 std::fs::create_dir_all(&dir)?;
633 let safe_name: String = session_name
635 .chars()
636 .map(|c| {
637 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
638 c
639 } else {
640 '_'
641 }
642 })
643 .collect();
644 Ok(dir.join(format!("{}.json", safe_name)))
645}
646
647#[derive(Debug)]
649pub enum WorkspaceError {
650 Io(anyhow::Error),
651 Json(serde_json::Error),
652 WorkdirMismatch { expected: PathBuf, found: PathBuf },
653 VersionTooNew { version: u32, max_supported: u32 },
654}
655
656impl std::fmt::Display for WorkspaceError {
657 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
658 match self {
659 Self::Io(e) => write!(f, "Workspace error: {}", e),
660 Self::Json(e) => write!(f, "JSON error: {}", e),
661 Self::WorkdirMismatch { expected, found } => {
662 write!(
663 f,
664 "Working directory mismatch: expected {:?}, found {:?}",
665 expected, found
666 )
667 }
668 WorkspaceError::VersionTooNew {
669 version,
670 max_supported,
671 } => {
672 write!(
673 f,
674 "Workspace version {} is newer than supported (max: {})",
675 version, max_supported
676 )
677 }
678 }
679 }
680}
681
682impl std::error::Error for WorkspaceError {
683 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
684 match self {
685 Self::Io(e) => e.source(),
686 Self::Json(e) => Some(e),
687 _ => None,
688 }
689 }
690}
691
692impl From<io::Error> for WorkspaceError {
693 fn from(e: io::Error) -> Self {
694 WorkspaceError::Io(e.into())
695 }
696}
697
698impl From<anyhow::Error> for WorkspaceError {
699 fn from(e: anyhow::Error) -> Self {
700 WorkspaceError::Io(e)
701 }
702}
703
704impl From<serde_json::Error> for WorkspaceError {
705 fn from(e: serde_json::Error) -> Self {
706 WorkspaceError::Json(e)
707 }
708}
709
710impl Workspace {
711 pub fn load(working_dir: &Path) -> Result<Option<Workspace>, WorkspaceError> {
713 let path = get_workspace_path(working_dir)?;
714 tracing::debug!("Looking for workspace at {:?}", path);
715
716 if !path.exists() {
717 tracing::debug!("Workspace file does not exist");
718 return Ok(None);
719 }
720
721 tracing::debug!("Loading workspace from {:?}", path);
722 let content = std::fs::read_to_string(&path)?;
723 let workspace: Workspace = serde_json::from_str(&content)?;
724
725 tracing::debug!(
726 "Loaded workspace: version={}, split_states={}, active_split={}",
727 workspace.version,
728 workspace.split_states.len(),
729 workspace.active_split_id
730 );
731
732 let expected = working_dir
734 .canonicalize()
735 .unwrap_or_else(|_| working_dir.to_path_buf());
736 let found = workspace
737 .working_dir
738 .canonicalize()
739 .unwrap_or_else(|_| workspace.working_dir.clone());
740
741 if expected != found {
742 tracing::warn!(
743 "Workspace working_dir mismatch: expected {:?}, found {:?}",
744 expected,
745 found
746 );
747 return Err(WorkspaceError::WorkdirMismatch { expected, found });
748 }
749
750 if workspace.version > WORKSPACE_VERSION {
752 tracing::warn!(
753 "Workspace version {} is newer than supported {}",
754 workspace.version,
755 WORKSPACE_VERSION
756 );
757 return Err(WorkspaceError::VersionTooNew {
758 version: workspace.version,
759 max_supported: WORKSPACE_VERSION,
760 });
761 }
762
763 Ok(Some(workspace))
764 }
765
766 pub fn save(&self) -> Result<(), WorkspaceError> {
773 let path = get_workspace_path(&self.working_dir)?;
774 tracing::debug!("Saving workspace to {:?}", path);
775
776 if let Some(parent) = path.parent() {
778 std::fs::create_dir_all(parent)?;
779 }
780
781 let content = serde_json::to_string_pretty(self)?;
783 tracing::trace!("Workspace JSON size: {} bytes", content.len());
784
785 let temp_path = path.with_extension("json.tmp");
787
788 {
790 let mut file = std::fs::File::create(&temp_path)?;
791 file.write_all(content.as_bytes())?;
792 file.sync_all()?; }
794
795 std::fs::rename(&temp_path, &path)?;
797 tracing::info!("Workspace saved to {:?}", path);
798
799 Ok(())
800 }
801
802 pub fn load_session(
804 session_name: &str,
805 working_dir: &Path,
806 ) -> Result<Option<Workspace>, WorkspaceError> {
807 let path = get_session_workspace_path(session_name)?;
808 tracing::debug!("Looking for session workspace at {:?}", path);
809
810 if !path.exists() {
811 return Ok(None);
812 }
813
814 let content = std::fs::read_to_string(&path)?;
815 let workspace: Workspace = serde_json::from_str(&content)?;
816
817 if workspace.version > WORKSPACE_VERSION {
820 return Err(WorkspaceError::VersionTooNew {
821 version: workspace.version,
822 max_supported: WORKSPACE_VERSION,
823 });
824 }
825
826 let found = workspace
828 .working_dir
829 .canonicalize()
830 .unwrap_or_else(|_| workspace.working_dir.clone());
831 let expected = working_dir
832 .canonicalize()
833 .unwrap_or_else(|_| working_dir.to_path_buf());
834 if expected != found {
835 tracing::info!(
836 "Session '{}' workspace was saved from {:?}, now loading from {:?}",
837 session_name,
838 found,
839 expected
840 );
841 }
842
843 Ok(Some(workspace))
844 }
845
846 pub fn save_session(&self, session_name: &str) -> Result<(), WorkspaceError> {
848 let path = get_session_workspace_path(session_name)?;
849 tracing::debug!("Saving session workspace to {:?}", path);
850
851 if let Some(parent) = path.parent() {
852 std::fs::create_dir_all(parent)?;
853 }
854
855 let content = serde_json::to_string_pretty(self)?;
856 let temp_path = path.with_extension("json.tmp");
857 {
858 let mut file = std::fs::File::create(&temp_path)?;
859 file.write_all(content.as_bytes())?;
860 file.sync_all()?;
861 }
862 std::fs::rename(&temp_path, &path)?;
863 tracing::info!("Session workspace saved to {:?}", path);
864 Ok(())
865 }
866
867 pub fn delete(working_dir: &Path) -> Result<(), WorkspaceError> {
869 let path = get_workspace_path(working_dir)?;
870 if path.exists() {
871 std::fs::remove_file(path)?;
872 }
873 Ok(())
874 }
875
876 pub fn new(working_dir: PathBuf) -> Self {
878 Self {
879 version: WORKSPACE_VERSION,
880 working_dir,
881 split_layout: SerializedSplitNode::Leaf {
882 file_path: None,
883 split_id: 0,
884 label: None,
885 unnamed_recovery_id: None,
886 },
887 active_split_id: 0,
888 split_states: HashMap::new(),
889 config_overrides: WorkspaceConfigOverrides::default(),
890 file_explorer: FileExplorerState::default(),
891 histories: WorkspaceHistories::default(),
892 search_options: SearchOptions::default(),
893 bookmarks: HashMap::new(),
894 terminals: Vec::new(),
895 external_files: Vec::new(),
896 read_only_files: Vec::new(),
897 unnamed_buffers: Vec::new(),
898 plugin_global_state: HashMap::new(),
899 saved_at: SystemTime::now()
900 .duration_since(UNIX_EPOCH)
901 .unwrap_or_default()
902 .as_secs(),
903 }
904 }
905
906 pub fn touch(&mut self) {
908 self.saved_at = SystemTime::now()
909 .duration_since(UNIX_EPOCH)
910 .unwrap_or_default()
911 .as_secs();
912 }
913}
914
915#[cfg(test)]
916mod tests {
917 use super::*;
918
919 #[test]
920 fn test_workspace_path_percent_encoding() {
921 let encoded = encode_path_for_filename(Path::new("/home/user/project"));
923 assert_eq!(encoded, "home_user_project");
924 assert!(!encoded.contains('/')); let decoded = decode_filename_to_path(&encoded).unwrap();
928 assert_eq!(decoded, PathBuf::from("/home/user/project"));
929
930 let path1 = get_workspace_path(Path::new("/home/user/project")).unwrap();
932 let path2 = get_workspace_path(Path::new("/home/user/other")).unwrap();
933 assert_ne!(path1, path2);
934
935 let path1_again = get_workspace_path(Path::new("/home/user/project")).unwrap();
937 assert_eq!(path1, path1_again);
938
939 let filename = path1.file_name().unwrap().to_str().unwrap();
941 assert!(filename.ends_with(".json"));
942 assert!(filename.starts_with("home_user_project"));
943 }
944
945 #[test]
946 fn test_percent_encoding_edge_cases() {
947 let encoded = encode_path_for_filename(Path::new("/home/user/my-project"));
949 assert_eq!(encoded, "home_user_my-project");
950
951 let encoded = encode_path_for_filename(Path::new("/home/user/my project"));
953 assert_eq!(encoded, "home_user_my%20project");
954 let decoded = decode_filename_to_path(&encoded).unwrap();
955 assert_eq!(decoded, PathBuf::from("/home/user/my project"));
956
957 let encoded = encode_path_for_filename(Path::new("/home/user/my_project"));
959 assert_eq!(encoded, "home_user_my%5Fproject");
960 let decoded = decode_filename_to_path(&encoded).unwrap();
961 assert_eq!(decoded, PathBuf::from("/home/user/my_project"));
962
963 let encoded = encode_path_for_filename(Path::new("/"));
965 assert_eq!(encoded, "root");
966 }
967
968 #[test]
969 fn test_workspace_serialization() {
970 let workspace = Workspace::new(PathBuf::from("/home/user/test"));
971 let json = serde_json::to_string(&workspace).unwrap();
972 let restored: Workspace = serde_json::from_str(&json).unwrap();
973
974 assert_eq!(workspace.version, restored.version);
975 assert_eq!(workspace.working_dir, restored.working_dir);
976 }
977
978 #[test]
979 fn test_workspace_config_overrides_skip_none() {
980 let overrides = WorkspaceConfigOverrides::default();
981 let json = serde_json::to_string(&overrides).unwrap();
982
983 assert_eq!(json, "{}");
985 }
986
987 #[test]
988 fn test_workspace_config_overrides_with_values() {
989 let overrides = WorkspaceConfigOverrides {
990 line_wrap: Some(false),
991 ..Default::default()
992 };
993 let json = serde_json::to_string(&overrides).unwrap();
994
995 assert!(json.contains("line_wrap"));
996 assert!(!json.contains("line_numbers")); }
998
999 #[test]
1000 fn test_split_layout_serialization() {
1001 let layout = SerializedSplitNode::Split {
1003 direction: SerializedSplitDirection::Vertical,
1004 first: Box::new(SerializedSplitNode::Leaf {
1005 file_path: Some(PathBuf::from("src/main.rs")),
1006 split_id: 1,
1007 label: None,
1008 unnamed_recovery_id: None,
1009 }),
1010 second: Box::new(SerializedSplitNode::Leaf {
1011 file_path: Some(PathBuf::from("src/lib.rs")),
1012 split_id: 2,
1013 label: None,
1014 unnamed_recovery_id: None,
1015 }),
1016 ratio: 0.5,
1017 split_id: 0,
1018 };
1019
1020 let json = serde_json::to_string(&layout).unwrap();
1021 let restored: SerializedSplitNode = serde_json::from_str(&json).unwrap();
1022
1023 match restored {
1025 SerializedSplitNode::Split {
1026 direction,
1027 ratio,
1028 split_id,
1029 ..
1030 } => {
1031 assert!(matches!(direction, SerializedSplitDirection::Vertical));
1032 assert_eq!(ratio, 0.5);
1033 assert_eq!(split_id, 0);
1034 }
1035 _ => panic!("Expected Split node"),
1036 }
1037 }
1038
1039 #[test]
1040 fn test_file_state_serialization() {
1041 let file_state = SerializedFileState {
1042 cursor: SerializedCursor {
1043 position: 1234,
1044 anchor: Some(1000),
1045 sticky_column: 15,
1046 },
1047 additional_cursors: vec![SerializedCursor {
1048 position: 5000,
1049 anchor: None,
1050 sticky_column: 0,
1051 }],
1052 scroll: SerializedScroll {
1053 top_byte: 500,
1054 top_view_line_offset: 2,
1055 left_column: 10,
1056 },
1057 view_mode: SerializedViewMode::Source,
1058 compose_width: None,
1059 plugin_state: HashMap::new(),
1060 folds: Vec::new(),
1061 };
1062
1063 let json = serde_json::to_string(&file_state).unwrap();
1064 let restored: SerializedFileState = serde_json::from_str(&json).unwrap();
1065
1066 assert_eq!(restored.cursor.position, 1234);
1067 assert_eq!(restored.cursor.anchor, Some(1000));
1068 assert_eq!(restored.cursor.sticky_column, 15);
1069 assert_eq!(restored.additional_cursors.len(), 1);
1070 assert_eq!(restored.scroll.top_byte, 500);
1071 assert_eq!(restored.scroll.left_column, 10);
1072 }
1073
1074 #[test]
1075 fn test_bookmark_serialization() {
1076 let mut bookmarks = HashMap::new();
1077 bookmarks.insert(
1078 'a',
1079 SerializedBookmark {
1080 file_path: PathBuf::from("src/main.rs"),
1081 position: 1234,
1082 },
1083 );
1084 bookmarks.insert(
1085 'b',
1086 SerializedBookmark {
1087 file_path: PathBuf::from("src/lib.rs"),
1088 position: 5678,
1089 },
1090 );
1091
1092 let json = serde_json::to_string(&bookmarks).unwrap();
1093 let restored: HashMap<char, SerializedBookmark> = serde_json::from_str(&json).unwrap();
1094
1095 assert_eq!(restored.len(), 2);
1096 assert_eq!(restored.get(&'a').unwrap().position, 1234);
1097 assert_eq!(
1098 restored.get(&'b').unwrap().file_path,
1099 PathBuf::from("src/lib.rs")
1100 );
1101 }
1102
1103 #[test]
1104 fn test_search_options_serialization() {
1105 let options = SearchOptions {
1106 case_sensitive: true,
1107 whole_word: true,
1108 use_regex: false,
1109 confirm_each: true,
1110 };
1111
1112 let json = serde_json::to_string(&options).unwrap();
1113 let restored: SearchOptions = serde_json::from_str(&json).unwrap();
1114
1115 assert!(restored.case_sensitive);
1116 assert!(restored.whole_word);
1117 assert!(!restored.use_regex);
1118 assert!(restored.confirm_each);
1119 }
1120
1121 #[test]
1122 fn test_full_workspace_round_trip() {
1123 let mut workspace = Workspace::new(PathBuf::from("/home/user/myproject"));
1124
1125 workspace.split_layout = SerializedSplitNode::Split {
1127 direction: SerializedSplitDirection::Horizontal,
1128 first: Box::new(SerializedSplitNode::Leaf {
1129 file_path: Some(PathBuf::from("README.md")),
1130 split_id: 1,
1131 label: None,
1132 unnamed_recovery_id: None,
1133 }),
1134 second: Box::new(SerializedSplitNode::Leaf {
1135 file_path: Some(PathBuf::from("Cargo.toml")),
1136 split_id: 2,
1137 label: None,
1138 unnamed_recovery_id: None,
1139 }),
1140 ratio: 0.6,
1141 split_id: 0,
1142 };
1143 workspace.active_split_id = 1;
1144
1145 workspace.split_states.insert(
1147 1,
1148 SerializedSplitViewState {
1149 open_tabs: vec![
1150 SerializedTabRef::File(PathBuf::from("README.md")),
1151 SerializedTabRef::File(PathBuf::from("src/lib.rs")),
1152 ],
1153 active_tab_index: Some(0),
1154 open_files: vec![PathBuf::from("README.md"), PathBuf::from("src/lib.rs")],
1155 active_file_index: 0,
1156 file_states: HashMap::new(),
1157 tab_scroll_offset: 0,
1158 view_mode: SerializedViewMode::Source,
1159 compose_width: None,
1160 },
1161 );
1162
1163 workspace.bookmarks.insert(
1165 'm',
1166 SerializedBookmark {
1167 file_path: PathBuf::from("src/main.rs"),
1168 position: 100,
1169 },
1170 );
1171
1172 workspace.search_options.case_sensitive = true;
1174 workspace.search_options.use_regex = true;
1175
1176 let json = serde_json::to_string_pretty(&workspace).unwrap();
1178 let restored: Workspace = serde_json::from_str(&json).unwrap();
1179
1180 assert_eq!(restored.version, WORKSPACE_VERSION);
1182 assert_eq!(restored.working_dir, PathBuf::from("/home/user/myproject"));
1183 assert_eq!(restored.active_split_id, 1);
1184 assert!(restored.bookmarks.contains_key(&'m'));
1185 assert!(restored.search_options.case_sensitive);
1186 assert!(restored.search_options.use_regex);
1187
1188 let split_state = restored.split_states.get(&1).unwrap();
1190 assert_eq!(split_state.open_files.len(), 2);
1191 assert_eq!(split_state.open_files[0], PathBuf::from("README.md"));
1192 }
1193
1194 #[test]
1195 fn test_workspace_file_save_load() {
1196 use std::fs;
1197
1198 let temp_dir = std::env::temp_dir().join("fresh_workspace_test");
1200 drop(fs::remove_dir_all(&temp_dir)); fs::create_dir_all(&temp_dir).unwrap();
1202
1203 let workspace_path = temp_dir.join("test_workspace.json");
1204
1205 let mut workspace = Workspace::new(temp_dir.clone());
1207 workspace.search_options.case_sensitive = true;
1208 workspace.bookmarks.insert(
1209 'x',
1210 SerializedBookmark {
1211 file_path: PathBuf::from("test.txt"),
1212 position: 42,
1213 },
1214 );
1215
1216 let content = serde_json::to_string_pretty(&workspace).unwrap();
1218 let temp_path = workspace_path.with_extension("json.tmp");
1219 let mut file = std::fs::File::create(&temp_path).unwrap();
1220 std::io::Write::write_all(&mut file, content.as_bytes()).unwrap();
1221 file.sync_all().unwrap();
1222 std::fs::rename(&temp_path, &workspace_path).unwrap();
1223
1224 let loaded_content = fs::read_to_string(&workspace_path).unwrap();
1226 let loaded: Workspace = serde_json::from_str(&loaded_content).unwrap();
1227
1228 assert_eq!(loaded.working_dir, temp_dir);
1230 assert!(loaded.search_options.case_sensitive);
1231 assert_eq!(loaded.bookmarks.get(&'x').unwrap().position, 42);
1232
1233 drop(fs::remove_dir_all(&temp_dir));
1235 }
1236
1237 #[test]
1238 fn test_workspace_version_check() {
1239 let workspace = Workspace::new(PathBuf::from("/test"));
1240 assert_eq!(workspace.version, WORKSPACE_VERSION);
1241
1242 let mut json_value: serde_json::Value = serde_json::to_value(&workspace).unwrap();
1244 json_value["version"] = serde_json::json!(999);
1245
1246 let json = serde_json::to_string(&json_value).unwrap();
1247 let restored: Workspace = serde_json::from_str(&json).unwrap();
1248
1249 assert_eq!(restored.version, 999);
1251 }
1252
1253 #[test]
1254 fn test_empty_workspace_histories() {
1255 let histories = WorkspaceHistories::default();
1256 let json = serde_json::to_string(&histories).unwrap();
1257
1258 assert_eq!(json, "{}");
1260
1261 let restored: WorkspaceHistories = serde_json::from_str(&json).unwrap();
1263 assert!(restored.search.is_empty());
1264 assert!(restored.replace.is_empty());
1265 }
1266
1267 #[test]
1268 fn test_file_explorer_state() {
1269 let state = FileExplorerState {
1270 visible: true,
1271 width_percent: 0.25,
1272 expanded_dirs: vec![
1273 PathBuf::from("src"),
1274 PathBuf::from("src/app"),
1275 PathBuf::from("tests"),
1276 ],
1277 scroll_offset: 5,
1278 show_hidden: true,
1279 show_gitignored: false,
1280 };
1281
1282 let json = serde_json::to_string(&state).unwrap();
1283 let restored: FileExplorerState = serde_json::from_str(&json).unwrap();
1284
1285 assert!(restored.visible);
1286 assert_eq!(restored.width_percent, 0.25);
1287 assert_eq!(restored.expanded_dirs.len(), 3);
1288 assert_eq!(restored.scroll_offset, 5);
1289 assert!(restored.show_hidden);
1290 assert!(!restored.show_gitignored);
1291 }
1292}