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")]
88 pub unnamed_buffers: Vec<UnnamedBufferRef>,
89
90 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
94 pub plugin_global_state: HashMap<String, HashMap<String, serde_json::Value>>,
95
96 pub saved_at: u64,
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct UnnamedBufferRef {
103 pub recovery_id: String,
105 pub display_name: String,
107}
108
109#[derive(Debug, Clone, Serialize, Deserialize)]
111pub enum SerializedSplitNode {
112 Leaf {
113 file_path: Option<PathBuf>,
115 split_id: usize,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 label: Option<String>,
119 #[serde(default, skip_serializing_if = "Option::is_none")]
121 unnamed_recovery_id: Option<String>,
122 },
123 Terminal {
124 terminal_index: usize,
125 split_id: usize,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
128 label: Option<String>,
129 },
130 Split {
131 direction: SerializedSplitDirection,
132 first: Box<Self>,
133 second: Box<Self>,
134 ratio: f32,
135 split_id: usize,
136 },
137}
138
139#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
140pub enum SerializedSplitDirection {
141 Horizontal,
142 Vertical,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct SerializedSplitViewState {
148 #[serde(default)]
150 pub open_tabs: Vec<SerializedTabRef>,
151
152 #[serde(default)]
154 pub active_tab_index: Option<usize>,
155
156 #[serde(default)]
159 pub open_files: Vec<PathBuf>,
160
161 #[serde(default)]
163 pub active_file_index: usize,
164
165 #[serde(default)]
167 pub file_states: HashMap<PathBuf, SerializedFileState>,
168
169 #[serde(default)]
171 pub tab_scroll_offset: usize,
172
173 #[serde(default)]
175 pub view_mode: SerializedViewMode,
176
177 #[serde(default)]
179 pub compose_width: Option<u16>,
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct SerializedFileState {
185 pub cursor: SerializedCursor,
187
188 #[serde(default)]
190 pub additional_cursors: Vec<SerializedCursor>,
191
192 pub scroll: SerializedScroll,
194
195 #[serde(default)]
197 pub view_mode: SerializedViewMode,
198
199 #[serde(default)]
201 pub compose_width: Option<u16>,
202
203 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
205 pub plugin_state: HashMap<String, serde_json::Value>,
206
207 #[serde(default, skip_serializing_if = "Vec::is_empty")]
209 pub folds: Vec<SerializedFoldRange>,
210}
211
212#[derive(Debug, Clone, Serialize, Deserialize)]
214pub struct SerializedFoldRange {
215 pub header_line: usize,
217 pub end_line: usize,
219 #[serde(default)]
221 pub placeholder: Option<String>,
222}
223
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct SerializedCursor {
226 pub position: usize,
228 #[serde(default)]
230 pub anchor: Option<usize>,
231 #[serde(default)]
233 pub sticky_column: usize,
234}
235
236#[derive(Debug, Clone, Serialize, Deserialize)]
237pub struct SerializedScroll {
238 pub top_byte: usize,
240 #[serde(default)]
242 pub top_view_line_offset: usize,
243 #[serde(default)]
245 pub left_column: usize,
246}
247
248#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
249pub enum SerializedViewMode {
250 #[default]
251 Source,
252 #[serde(alias = "Compose")]
255 PageView,
256}
257
258#[derive(Debug, Clone, Default, Serialize, Deserialize)]
260pub struct WorkspaceConfigOverrides {
261 #[serde(default, skip_serializing_if = "Option::is_none")]
262 pub line_numbers: Option<bool>,
263 #[serde(default, skip_serializing_if = "Option::is_none")]
264 pub relative_line_numbers: Option<bool>,
265 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub line_wrap: Option<bool>,
267 #[serde(default, skip_serializing_if = "Option::is_none")]
268 pub syntax_highlighting: Option<bool>,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub enable_inlay_hints: Option<bool>,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub mouse_enabled: Option<bool>,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
274 pub menu_bar_hidden: Option<bool>,
275}
276
277#[derive(Debug, Clone, Serialize, Deserialize)]
278pub struct FileExplorerState {
279 pub visible: bool,
280 #[serde(default)]
281 pub width_percent: f32,
282 #[serde(default)]
284 pub expanded_dirs: Vec<PathBuf>,
285 #[serde(default)]
287 pub scroll_offset: usize,
288 #[serde(default)]
290 pub show_hidden: bool,
291 #[serde(default)]
293 pub show_gitignored: bool,
294}
295
296impl Default for FileExplorerState {
297 fn default() -> Self {
298 Self {
299 visible: false,
300 width_percent: 0.3,
301 expanded_dirs: Vec::new(),
302 scroll_offset: 0,
303 show_hidden: false,
304 show_gitignored: false,
305 }
306 }
307}
308
309#[derive(Debug, Clone, Default, Serialize, Deserialize)]
311pub struct WorkspaceHistories {
312 #[serde(default, skip_serializing_if = "Vec::is_empty")]
313 pub search: Vec<String>,
314 #[serde(default, skip_serializing_if = "Vec::is_empty")]
315 pub replace: Vec<String>,
316 #[serde(default, skip_serializing_if = "Vec::is_empty")]
317 pub command_palette: Vec<String>,
318 #[serde(default, skip_serializing_if = "Vec::is_empty")]
319 pub goto_line: Vec<String>,
320 #[serde(default, skip_serializing_if = "Vec::is_empty")]
321 pub open_file: Vec<String>,
322}
323
324#[derive(Debug, Clone, Default, Serialize, Deserialize)]
326pub struct SearchOptions {
327 #[serde(default)]
328 pub case_sensitive: bool,
329 #[serde(default)]
330 pub whole_word: bool,
331 #[serde(default)]
332 pub use_regex: bool,
333 #[serde(default)]
334 pub confirm_each: bool,
335}
336
337#[derive(Debug, Clone, Serialize, Deserialize)]
339pub struct SerializedBookmark {
340 pub file_path: PathBuf,
342 pub position: usize,
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize)]
348pub enum SerializedTabRef {
349 File(PathBuf),
350 Terminal(usize),
351 Unnamed(String),
353}
354
355#[derive(Debug, Clone, Serialize, Deserialize)]
357pub struct SerializedTerminalWorkspace {
358 pub terminal_index: usize,
359 pub cwd: Option<PathBuf>,
360 pub shell: String,
361 pub cols: u16,
362 pub rows: u16,
363 pub log_path: PathBuf,
364 pub backing_path: PathBuf,
365}
366
367#[derive(Debug, Clone, Serialize, Deserialize)]
378pub struct PersistedFileState {
379 pub version: u32,
381
382 pub state: SerializedFileState,
384
385 pub saved_at: u64,
387}
388
389impl PersistedFileState {
390 fn new(state: SerializedFileState) -> Self {
391 Self {
392 version: FILE_WORKSPACE_VERSION,
393 state,
394 saved_at: SystemTime::now()
395 .duration_since(UNIX_EPOCH)
396 .unwrap_or_default()
397 .as_secs(),
398 }
399 }
400}
401
402pub struct PersistedFileWorkspace;
414
415impl PersistedFileWorkspace {
416 fn states_dir() -> io::Result<PathBuf> {
418 Ok(get_data_dir()?.join("file_states"))
419 }
420
421 fn state_file_path(source_path: &Path) -> io::Result<PathBuf> {
423 let canonical = source_path
424 .canonicalize()
425 .unwrap_or_else(|_| source_path.to_path_buf());
426 let filename = format!("{}.json", encode_path_for_filename(&canonical));
427 Ok(Self::states_dir()?.join(filename))
428 }
429
430 pub fn load(path: &Path) -> Option<SerializedFileState> {
432 let state_path = match Self::state_file_path(path) {
433 Ok(p) => p,
434 Err(_) => return None,
435 };
436
437 if !state_path.exists() {
438 return None;
439 }
440
441 let content = match std::fs::read_to_string(&state_path) {
442 Ok(c) => c,
443 Err(_) => return None,
444 };
445
446 let persisted: PersistedFileState = match serde_json::from_str(&content) {
447 Ok(p) => p,
448 Err(_) => return None,
449 };
450
451 if persisted.version > FILE_WORKSPACE_VERSION {
453 return None;
454 }
455
456 Some(persisted.state)
457 }
458
459 pub fn save(path: &Path, state: SerializedFileState) {
461 let state_path = match Self::state_file_path(path) {
462 Ok(p) => p,
463 Err(e) => {
464 tracing::warn!("Failed to get state path for {:?}: {}", path, e);
465 return;
466 }
467 };
468
469 if let Some(parent) = state_path.parent() {
471 if let Err(e) = std::fs::create_dir_all(parent) {
472 tracing::warn!("Failed to create state dir: {}", e);
473 return;
474 }
475 }
476
477 let persisted = PersistedFileState::new(state);
478 let content = match serde_json::to_string_pretty(&persisted) {
479 Ok(c) => c,
480 Err(e) => {
481 tracing::warn!("Failed to serialize file state: {}", e);
482 return;
483 }
484 };
485
486 let temp_path = state_path.with_extension("json.tmp");
488
489 let write_result = (|| -> io::Result<()> {
490 let mut file = std::fs::File::create(&temp_path)?;
491 file.write_all(content.as_bytes())?;
492 file.sync_all()?;
493 std::fs::rename(&temp_path, &state_path)?;
494 Ok(())
495 })();
496
497 if let Err(e) = write_result {
498 tracing::warn!("Failed to save file state for {:?}: {}", path, e);
499 } else {
500 tracing::trace!("File state saved for {:?}", path);
501 }
502 }
503}
504
505pub fn get_workspaces_dir() -> io::Result<PathBuf> {
511 Ok(get_data_dir()?.join("workspaces"))
512}
513
514pub fn encode_path_for_filename(path: &Path) -> String {
522 let path_str = path.to_string_lossy();
523 let mut result = String::with_capacity(path_str.len() * 2);
524
525 for c in path_str.chars() {
526 match c {
527 '/' | '\\' => result.push('_'),
529 c if c.is_ascii_alphanumeric() => result.push(c),
531 '-' | '.' => result.push(c),
532 '_' => result.push_str("%5F"),
534 c => {
536 for byte in c.to_string().as_bytes() {
537 result.push_str(&format!("%{:02X}", byte));
538 }
539 }
540 }
541 }
542
543 let result = result.trim_start_matches('_').to_string();
545
546 let mut final_result = String::with_capacity(result.len());
548 let mut last_was_underscore = false;
549 for c in result.chars() {
550 if c == '_' {
551 if !last_was_underscore {
552 final_result.push(c);
553 }
554 last_was_underscore = true;
555 } else {
556 final_result.push(c);
557 last_was_underscore = false;
558 }
559 }
560
561 if final_result.is_empty() {
562 final_result = "root".to_string();
563 }
564
565 final_result
566}
567
568#[allow(dead_code)]
570pub fn decode_filename_to_path(encoded: &str) -> Option<PathBuf> {
571 if encoded == "root" {
572 return Some(PathBuf::from("/"));
573 }
574
575 let mut result = String::with_capacity(encoded.len() + 1);
576 result.push('/');
578
579 let mut chars = encoded.chars().peekable();
580
581 while let Some(c) = chars.next() {
582 if c == '%' {
583 let hex: String = chars.by_ref().take(2).collect();
585 if hex.len() == 2 {
586 if let Ok(byte) = u8::from_str_radix(&hex, 16) {
587 result.push(byte as char);
588 }
589 }
590 } else if c == '_' {
591 result.push('/');
592 } else {
593 result.push(c);
594 }
595 }
596
597 Some(PathBuf::from(result))
598}
599
600pub fn get_workspace_path(working_dir: &Path) -> io::Result<PathBuf> {
602 let canonical = working_dir
603 .canonicalize()
604 .unwrap_or_else(|_| working_dir.to_path_buf());
605 let filename = format!("{}.json", encode_path_for_filename(&canonical));
606 Ok(get_workspaces_dir()?.join(filename))
607}
608
609pub fn get_session_workspaces_dir() -> io::Result<PathBuf> {
611 Ok(get_data_dir()?.join("session-workspaces"))
612}
613
614pub fn get_session_workspace_path(session_name: &str) -> io::Result<PathBuf> {
616 let dir = get_session_workspaces_dir()?;
617 std::fs::create_dir_all(&dir)?;
618 let safe_name: String = session_name
620 .chars()
621 .map(|c| {
622 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
623 c
624 } else {
625 '_'
626 }
627 })
628 .collect();
629 Ok(dir.join(format!("{}.json", safe_name)))
630}
631
632#[derive(Debug)]
634pub enum WorkspaceError {
635 Io(anyhow::Error),
636 Json(serde_json::Error),
637 WorkdirMismatch { expected: PathBuf, found: PathBuf },
638 VersionTooNew { version: u32, max_supported: u32 },
639}
640
641impl std::fmt::Display for WorkspaceError {
642 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
643 match self {
644 Self::Io(e) => write!(f, "Workspace error: {}", e),
645 Self::Json(e) => write!(f, "JSON error: {}", e),
646 Self::WorkdirMismatch { expected, found } => {
647 write!(
648 f,
649 "Working directory mismatch: expected {:?}, found {:?}",
650 expected, found
651 )
652 }
653 WorkspaceError::VersionTooNew {
654 version,
655 max_supported,
656 } => {
657 write!(
658 f,
659 "Workspace version {} is newer than supported (max: {})",
660 version, max_supported
661 )
662 }
663 }
664 }
665}
666
667impl std::error::Error for WorkspaceError {
668 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
669 match self {
670 Self::Io(e) => e.source(),
671 Self::Json(e) => Some(e),
672 _ => None,
673 }
674 }
675}
676
677impl From<io::Error> for WorkspaceError {
678 fn from(e: io::Error) -> Self {
679 WorkspaceError::Io(e.into())
680 }
681}
682
683impl From<anyhow::Error> for WorkspaceError {
684 fn from(e: anyhow::Error) -> Self {
685 WorkspaceError::Io(e)
686 }
687}
688
689impl From<serde_json::Error> for WorkspaceError {
690 fn from(e: serde_json::Error) -> Self {
691 WorkspaceError::Json(e)
692 }
693}
694
695impl Workspace {
696 pub fn load(working_dir: &Path) -> Result<Option<Workspace>, WorkspaceError> {
698 let path = get_workspace_path(working_dir)?;
699 tracing::debug!("Looking for workspace at {:?}", path);
700
701 if !path.exists() {
702 tracing::debug!("Workspace file does not exist");
703 return Ok(None);
704 }
705
706 tracing::debug!("Loading workspace from {:?}", path);
707 let content = std::fs::read_to_string(&path)?;
708 let workspace: Workspace = serde_json::from_str(&content)?;
709
710 tracing::debug!(
711 "Loaded workspace: version={}, split_states={}, active_split={}",
712 workspace.version,
713 workspace.split_states.len(),
714 workspace.active_split_id
715 );
716
717 let expected = working_dir
719 .canonicalize()
720 .unwrap_or_else(|_| working_dir.to_path_buf());
721 let found = workspace
722 .working_dir
723 .canonicalize()
724 .unwrap_or_else(|_| workspace.working_dir.clone());
725
726 if expected != found {
727 tracing::warn!(
728 "Workspace working_dir mismatch: expected {:?}, found {:?}",
729 expected,
730 found
731 );
732 return Err(WorkspaceError::WorkdirMismatch { expected, found });
733 }
734
735 if workspace.version > WORKSPACE_VERSION {
737 tracing::warn!(
738 "Workspace version {} is newer than supported {}",
739 workspace.version,
740 WORKSPACE_VERSION
741 );
742 return Err(WorkspaceError::VersionTooNew {
743 version: workspace.version,
744 max_supported: WORKSPACE_VERSION,
745 });
746 }
747
748 Ok(Some(workspace))
749 }
750
751 pub fn save(&self) -> Result<(), WorkspaceError> {
758 let path = get_workspace_path(&self.working_dir)?;
759 tracing::debug!("Saving workspace to {:?}", path);
760
761 if let Some(parent) = path.parent() {
763 std::fs::create_dir_all(parent)?;
764 }
765
766 let content = serde_json::to_string_pretty(self)?;
768 tracing::trace!("Workspace JSON size: {} bytes", content.len());
769
770 let temp_path = path.with_extension("json.tmp");
772
773 {
775 let mut file = std::fs::File::create(&temp_path)?;
776 file.write_all(content.as_bytes())?;
777 file.sync_all()?; }
779
780 std::fs::rename(&temp_path, &path)?;
782 tracing::info!("Workspace saved to {:?}", path);
783
784 Ok(())
785 }
786
787 pub fn load_session(
789 session_name: &str,
790 working_dir: &Path,
791 ) -> Result<Option<Workspace>, WorkspaceError> {
792 let path = get_session_workspace_path(session_name)?;
793 tracing::debug!("Looking for session workspace at {:?}", path);
794
795 if !path.exists() {
796 return Ok(None);
797 }
798
799 let content = std::fs::read_to_string(&path)?;
800 let workspace: Workspace = serde_json::from_str(&content)?;
801
802 if workspace.version > WORKSPACE_VERSION {
805 return Err(WorkspaceError::VersionTooNew {
806 version: workspace.version,
807 max_supported: WORKSPACE_VERSION,
808 });
809 }
810
811 let found = workspace
813 .working_dir
814 .canonicalize()
815 .unwrap_or_else(|_| workspace.working_dir.clone());
816 let expected = working_dir
817 .canonicalize()
818 .unwrap_or_else(|_| working_dir.to_path_buf());
819 if expected != found {
820 tracing::info!(
821 "Session '{}' workspace was saved from {:?}, now loading from {:?}",
822 session_name,
823 found,
824 expected
825 );
826 }
827
828 Ok(Some(workspace))
829 }
830
831 pub fn save_session(&self, session_name: &str) -> Result<(), WorkspaceError> {
833 let path = get_session_workspace_path(session_name)?;
834 tracing::debug!("Saving session workspace to {:?}", path);
835
836 if let Some(parent) = path.parent() {
837 std::fs::create_dir_all(parent)?;
838 }
839
840 let content = serde_json::to_string_pretty(self)?;
841 let temp_path = path.with_extension("json.tmp");
842 {
843 let mut file = std::fs::File::create(&temp_path)?;
844 file.write_all(content.as_bytes())?;
845 file.sync_all()?;
846 }
847 std::fs::rename(&temp_path, &path)?;
848 tracing::info!("Session workspace saved to {:?}", path);
849 Ok(())
850 }
851
852 pub fn delete(working_dir: &Path) -> Result<(), WorkspaceError> {
854 let path = get_workspace_path(working_dir)?;
855 if path.exists() {
856 std::fs::remove_file(path)?;
857 }
858 Ok(())
859 }
860
861 pub fn new(working_dir: PathBuf) -> Self {
863 Self {
864 version: WORKSPACE_VERSION,
865 working_dir,
866 split_layout: SerializedSplitNode::Leaf {
867 file_path: None,
868 split_id: 0,
869 label: None,
870 unnamed_recovery_id: None,
871 },
872 active_split_id: 0,
873 split_states: HashMap::new(),
874 config_overrides: WorkspaceConfigOverrides::default(),
875 file_explorer: FileExplorerState::default(),
876 histories: WorkspaceHistories::default(),
877 search_options: SearchOptions::default(),
878 bookmarks: HashMap::new(),
879 terminals: Vec::new(),
880 external_files: Vec::new(),
881 unnamed_buffers: Vec::new(),
882 plugin_global_state: HashMap::new(),
883 saved_at: SystemTime::now()
884 .duration_since(UNIX_EPOCH)
885 .unwrap_or_default()
886 .as_secs(),
887 }
888 }
889
890 pub fn touch(&mut self) {
892 self.saved_at = SystemTime::now()
893 .duration_since(UNIX_EPOCH)
894 .unwrap_or_default()
895 .as_secs();
896 }
897}
898
899#[cfg(test)]
900mod tests {
901 use super::*;
902
903 #[test]
904 fn test_workspace_path_percent_encoding() {
905 let encoded = encode_path_for_filename(Path::new("/home/user/project"));
907 assert_eq!(encoded, "home_user_project");
908 assert!(!encoded.contains('/')); let decoded = decode_filename_to_path(&encoded).unwrap();
912 assert_eq!(decoded, PathBuf::from("/home/user/project"));
913
914 let path1 = get_workspace_path(Path::new("/home/user/project")).unwrap();
916 let path2 = get_workspace_path(Path::new("/home/user/other")).unwrap();
917 assert_ne!(path1, path2);
918
919 let path1_again = get_workspace_path(Path::new("/home/user/project")).unwrap();
921 assert_eq!(path1, path1_again);
922
923 let filename = path1.file_name().unwrap().to_str().unwrap();
925 assert!(filename.ends_with(".json"));
926 assert!(filename.starts_with("home_user_project"));
927 }
928
929 #[test]
930 fn test_percent_encoding_edge_cases() {
931 let encoded = encode_path_for_filename(Path::new("/home/user/my-project"));
933 assert_eq!(encoded, "home_user_my-project");
934
935 let encoded = encode_path_for_filename(Path::new("/home/user/my project"));
937 assert_eq!(encoded, "home_user_my%20project");
938 let decoded = decode_filename_to_path(&encoded).unwrap();
939 assert_eq!(decoded, PathBuf::from("/home/user/my project"));
940
941 let encoded = encode_path_for_filename(Path::new("/home/user/my_project"));
943 assert_eq!(encoded, "home_user_my%5Fproject");
944 let decoded = decode_filename_to_path(&encoded).unwrap();
945 assert_eq!(decoded, PathBuf::from("/home/user/my_project"));
946
947 let encoded = encode_path_for_filename(Path::new("/"));
949 assert_eq!(encoded, "root");
950 }
951
952 #[test]
953 fn test_workspace_serialization() {
954 let workspace = Workspace::new(PathBuf::from("/home/user/test"));
955 let json = serde_json::to_string(&workspace).unwrap();
956 let restored: Workspace = serde_json::from_str(&json).unwrap();
957
958 assert_eq!(workspace.version, restored.version);
959 assert_eq!(workspace.working_dir, restored.working_dir);
960 }
961
962 #[test]
963 fn test_workspace_config_overrides_skip_none() {
964 let overrides = WorkspaceConfigOverrides::default();
965 let json = serde_json::to_string(&overrides).unwrap();
966
967 assert_eq!(json, "{}");
969 }
970
971 #[test]
972 fn test_workspace_config_overrides_with_values() {
973 let overrides = WorkspaceConfigOverrides {
974 line_wrap: Some(false),
975 ..Default::default()
976 };
977 let json = serde_json::to_string(&overrides).unwrap();
978
979 assert!(json.contains("line_wrap"));
980 assert!(!json.contains("line_numbers")); }
982
983 #[test]
984 fn test_split_layout_serialization() {
985 let layout = SerializedSplitNode::Split {
987 direction: SerializedSplitDirection::Vertical,
988 first: Box::new(SerializedSplitNode::Leaf {
989 file_path: Some(PathBuf::from("src/main.rs")),
990 split_id: 1,
991 label: None,
992 unnamed_recovery_id: None,
993 }),
994 second: Box::new(SerializedSplitNode::Leaf {
995 file_path: Some(PathBuf::from("src/lib.rs")),
996 split_id: 2,
997 label: None,
998 unnamed_recovery_id: None,
999 }),
1000 ratio: 0.5,
1001 split_id: 0,
1002 };
1003
1004 let json = serde_json::to_string(&layout).unwrap();
1005 let restored: SerializedSplitNode = serde_json::from_str(&json).unwrap();
1006
1007 match restored {
1009 SerializedSplitNode::Split {
1010 direction,
1011 ratio,
1012 split_id,
1013 ..
1014 } => {
1015 assert!(matches!(direction, SerializedSplitDirection::Vertical));
1016 assert_eq!(ratio, 0.5);
1017 assert_eq!(split_id, 0);
1018 }
1019 _ => panic!("Expected Split node"),
1020 }
1021 }
1022
1023 #[test]
1024 fn test_file_state_serialization() {
1025 let file_state = SerializedFileState {
1026 cursor: SerializedCursor {
1027 position: 1234,
1028 anchor: Some(1000),
1029 sticky_column: 15,
1030 },
1031 additional_cursors: vec![SerializedCursor {
1032 position: 5000,
1033 anchor: None,
1034 sticky_column: 0,
1035 }],
1036 scroll: SerializedScroll {
1037 top_byte: 500,
1038 top_view_line_offset: 2,
1039 left_column: 10,
1040 },
1041 view_mode: SerializedViewMode::Source,
1042 compose_width: None,
1043 plugin_state: HashMap::new(),
1044 folds: Vec::new(),
1045 };
1046
1047 let json = serde_json::to_string(&file_state).unwrap();
1048 let restored: SerializedFileState = serde_json::from_str(&json).unwrap();
1049
1050 assert_eq!(restored.cursor.position, 1234);
1051 assert_eq!(restored.cursor.anchor, Some(1000));
1052 assert_eq!(restored.cursor.sticky_column, 15);
1053 assert_eq!(restored.additional_cursors.len(), 1);
1054 assert_eq!(restored.scroll.top_byte, 500);
1055 assert_eq!(restored.scroll.left_column, 10);
1056 }
1057
1058 #[test]
1059 fn test_bookmark_serialization() {
1060 let mut bookmarks = HashMap::new();
1061 bookmarks.insert(
1062 'a',
1063 SerializedBookmark {
1064 file_path: PathBuf::from("src/main.rs"),
1065 position: 1234,
1066 },
1067 );
1068 bookmarks.insert(
1069 'b',
1070 SerializedBookmark {
1071 file_path: PathBuf::from("src/lib.rs"),
1072 position: 5678,
1073 },
1074 );
1075
1076 let json = serde_json::to_string(&bookmarks).unwrap();
1077 let restored: HashMap<char, SerializedBookmark> = serde_json::from_str(&json).unwrap();
1078
1079 assert_eq!(restored.len(), 2);
1080 assert_eq!(restored.get(&'a').unwrap().position, 1234);
1081 assert_eq!(
1082 restored.get(&'b').unwrap().file_path,
1083 PathBuf::from("src/lib.rs")
1084 );
1085 }
1086
1087 #[test]
1088 fn test_search_options_serialization() {
1089 let options = SearchOptions {
1090 case_sensitive: true,
1091 whole_word: true,
1092 use_regex: false,
1093 confirm_each: true,
1094 };
1095
1096 let json = serde_json::to_string(&options).unwrap();
1097 let restored: SearchOptions = serde_json::from_str(&json).unwrap();
1098
1099 assert!(restored.case_sensitive);
1100 assert!(restored.whole_word);
1101 assert!(!restored.use_regex);
1102 assert!(restored.confirm_each);
1103 }
1104
1105 #[test]
1106 fn test_full_workspace_round_trip() {
1107 let mut workspace = Workspace::new(PathBuf::from("/home/user/myproject"));
1108
1109 workspace.split_layout = SerializedSplitNode::Split {
1111 direction: SerializedSplitDirection::Horizontal,
1112 first: Box::new(SerializedSplitNode::Leaf {
1113 file_path: Some(PathBuf::from("README.md")),
1114 split_id: 1,
1115 label: None,
1116 unnamed_recovery_id: None,
1117 }),
1118 second: Box::new(SerializedSplitNode::Leaf {
1119 file_path: Some(PathBuf::from("Cargo.toml")),
1120 split_id: 2,
1121 label: None,
1122 unnamed_recovery_id: None,
1123 }),
1124 ratio: 0.6,
1125 split_id: 0,
1126 };
1127 workspace.active_split_id = 1;
1128
1129 workspace.split_states.insert(
1131 1,
1132 SerializedSplitViewState {
1133 open_tabs: vec![
1134 SerializedTabRef::File(PathBuf::from("README.md")),
1135 SerializedTabRef::File(PathBuf::from("src/lib.rs")),
1136 ],
1137 active_tab_index: Some(0),
1138 open_files: vec![PathBuf::from("README.md"), PathBuf::from("src/lib.rs")],
1139 active_file_index: 0,
1140 file_states: HashMap::new(),
1141 tab_scroll_offset: 0,
1142 view_mode: SerializedViewMode::Source,
1143 compose_width: None,
1144 },
1145 );
1146
1147 workspace.bookmarks.insert(
1149 'm',
1150 SerializedBookmark {
1151 file_path: PathBuf::from("src/main.rs"),
1152 position: 100,
1153 },
1154 );
1155
1156 workspace.search_options.case_sensitive = true;
1158 workspace.search_options.use_regex = true;
1159
1160 let json = serde_json::to_string_pretty(&workspace).unwrap();
1162 let restored: Workspace = serde_json::from_str(&json).unwrap();
1163
1164 assert_eq!(restored.version, WORKSPACE_VERSION);
1166 assert_eq!(restored.working_dir, PathBuf::from("/home/user/myproject"));
1167 assert_eq!(restored.active_split_id, 1);
1168 assert!(restored.bookmarks.contains_key(&'m'));
1169 assert!(restored.search_options.case_sensitive);
1170 assert!(restored.search_options.use_regex);
1171
1172 let split_state = restored.split_states.get(&1).unwrap();
1174 assert_eq!(split_state.open_files.len(), 2);
1175 assert_eq!(split_state.open_files[0], PathBuf::from("README.md"));
1176 }
1177
1178 #[test]
1179 fn test_workspace_file_save_load() {
1180 use std::fs;
1181
1182 let temp_dir = std::env::temp_dir().join("fresh_workspace_test");
1184 drop(fs::remove_dir_all(&temp_dir)); fs::create_dir_all(&temp_dir).unwrap();
1186
1187 let workspace_path = temp_dir.join("test_workspace.json");
1188
1189 let mut workspace = Workspace::new(temp_dir.clone());
1191 workspace.search_options.case_sensitive = true;
1192 workspace.bookmarks.insert(
1193 'x',
1194 SerializedBookmark {
1195 file_path: PathBuf::from("test.txt"),
1196 position: 42,
1197 },
1198 );
1199
1200 let content = serde_json::to_string_pretty(&workspace).unwrap();
1202 let temp_path = workspace_path.with_extension("json.tmp");
1203 let mut file = std::fs::File::create(&temp_path).unwrap();
1204 std::io::Write::write_all(&mut file, content.as_bytes()).unwrap();
1205 file.sync_all().unwrap();
1206 std::fs::rename(&temp_path, &workspace_path).unwrap();
1207
1208 let loaded_content = fs::read_to_string(&workspace_path).unwrap();
1210 let loaded: Workspace = serde_json::from_str(&loaded_content).unwrap();
1211
1212 assert_eq!(loaded.working_dir, temp_dir);
1214 assert!(loaded.search_options.case_sensitive);
1215 assert_eq!(loaded.bookmarks.get(&'x').unwrap().position, 42);
1216
1217 drop(fs::remove_dir_all(&temp_dir));
1219 }
1220
1221 #[test]
1222 fn test_workspace_version_check() {
1223 let workspace = Workspace::new(PathBuf::from("/test"));
1224 assert_eq!(workspace.version, WORKSPACE_VERSION);
1225
1226 let mut json_value: serde_json::Value = serde_json::to_value(&workspace).unwrap();
1228 json_value["version"] = serde_json::json!(999);
1229
1230 let json = serde_json::to_string(&json_value).unwrap();
1231 let restored: Workspace = serde_json::from_str(&json).unwrap();
1232
1233 assert_eq!(restored.version, 999);
1235 }
1236
1237 #[test]
1238 fn test_empty_workspace_histories() {
1239 let histories = WorkspaceHistories::default();
1240 let json = serde_json::to_string(&histories).unwrap();
1241
1242 assert_eq!(json, "{}");
1244
1245 let restored: WorkspaceHistories = serde_json::from_str(&json).unwrap();
1247 assert!(restored.search.is_empty());
1248 assert!(restored.replace.is_empty());
1249 }
1250
1251 #[test]
1252 fn test_file_explorer_state() {
1253 let state = FileExplorerState {
1254 visible: true,
1255 width_percent: 0.25,
1256 expanded_dirs: vec![
1257 PathBuf::from("src"),
1258 PathBuf::from("src/app"),
1259 PathBuf::from("tests"),
1260 ],
1261 scroll_offset: 5,
1262 show_hidden: true,
1263 show_gitignored: false,
1264 };
1265
1266 let json = serde_json::to_string(&state).unwrap();
1267 let restored: FileExplorerState = serde_json::from_str(&json).unwrap();
1268
1269 assert!(restored.visible);
1270 assert_eq!(restored.width_percent, 0.25);
1271 assert_eq!(restored.expanded_dirs.len(), 3);
1272 assert_eq!(restored.scroll_offset, 5);
1273 assert!(restored.show_hidden);
1274 assert!(!restored.show_gitignored);
1275 }
1276}