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