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 #[serde(default, skip_serializing_if = "Option::is_none")]
109 pub label: Option<String>,
110
111 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
117 pub session_plugin_state: HashMap<String, HashMap<String, serde_json::Value>>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct UnnamedBufferRef {
123 pub recovery_id: String,
125 pub display_name: String,
127}
128
129#[derive(Debug, Clone, Serialize, Deserialize)]
131pub enum SerializedSplitNode {
132 Leaf {
133 file_path: Option<PathBuf>,
135 split_id: usize,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
138 label: Option<String>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
141 unnamed_recovery_id: Option<String>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
144 role: Option<crate::view::split::SplitRole>,
145 },
146 Terminal {
147 terminal_index: usize,
148 split_id: usize,
149 #[serde(default, skip_serializing_if = "Option::is_none")]
151 label: Option<String>,
152 #[serde(default, skip_serializing_if = "Option::is_none")]
154 role: Option<crate::view::split::SplitRole>,
155 },
156 Split {
157 direction: SerializedSplitDirection,
158 first: Box<Self>,
159 second: Box<Self>,
160 ratio: f32,
161 split_id: usize,
162 },
163}
164
165#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
166pub enum SerializedSplitDirection {
167 Horizontal,
168 Vertical,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct SerializedSplitViewState {
174 #[serde(default)]
176 pub open_tabs: Vec<SerializedTabRef>,
177
178 #[serde(default)]
180 pub active_tab_index: Option<usize>,
181
182 #[serde(default)]
185 pub open_files: Vec<PathBuf>,
186
187 #[serde(default)]
189 pub active_file_index: usize,
190
191 #[serde(default)]
193 pub file_states: HashMap<PathBuf, SerializedFileState>,
194
195 #[serde(default)]
197 pub tab_scroll_offset: usize,
198
199 #[serde(default)]
201 pub view_mode: SerializedViewMode,
202
203 #[serde(default)]
205 pub compose_width: Option<u16>,
206}
207
208#[derive(Debug, Clone, Serialize, Deserialize)]
210pub struct SerializedFileState {
211 pub cursor: SerializedCursor,
213
214 #[serde(default)]
216 pub additional_cursors: Vec<SerializedCursor>,
217
218 pub scroll: SerializedScroll,
220
221 #[serde(default)]
223 pub view_mode: SerializedViewMode,
224
225 #[serde(default)]
227 pub compose_width: Option<u16>,
228
229 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
231 pub plugin_state: HashMap<String, serde_json::Value>,
232
233 #[serde(default, skip_serializing_if = "Vec::is_empty")]
235 pub folds: Vec<SerializedFoldRange>,
236}
237
238#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct SerializedFoldRange {
241 pub header_line: usize,
243 pub end_line: usize,
245 #[serde(default)]
247 pub placeholder: Option<String>,
248 #[serde(default)]
257 pub header_text: Option<String>,
258}
259
260#[derive(Debug, Clone, Serialize, Deserialize)]
261pub struct SerializedCursor {
262 pub position: usize,
264 #[serde(default)]
266 pub anchor: Option<usize>,
267 #[serde(default)]
269 pub sticky_column: usize,
270}
271
272#[derive(Debug, Clone, Serialize, Deserialize)]
273pub struct SerializedScroll {
274 pub top_byte: usize,
276 #[serde(default)]
278 pub top_view_line_offset: usize,
279 #[serde(default)]
281 pub left_column: usize,
282}
283
284#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
285pub enum SerializedViewMode {
286 #[default]
287 Source,
288 #[serde(alias = "Compose")]
291 PageView,
292}
293
294#[derive(Debug, Clone, Default, Serialize, Deserialize)]
296pub struct WorkspaceConfigOverrides {
297 #[serde(default, skip_serializing_if = "Option::is_none")]
298 pub line_numbers: Option<bool>,
299 #[serde(default, skip_serializing_if = "Option::is_none")]
300 pub relative_line_numbers: Option<bool>,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
302 pub line_wrap: Option<bool>,
303 #[serde(default, skip_serializing_if = "Option::is_none")]
304 pub syntax_highlighting: Option<bool>,
305 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub enable_inlay_hints: Option<bool>,
307 #[serde(default, skip_serializing_if = "Option::is_none")]
308 pub mouse_enabled: Option<bool>,
309 #[serde(default, skip_serializing_if = "Option::is_none")]
315 pub menu_bar_hidden: Option<bool>,
316}
317
318#[derive(Debug, Clone, Serialize, Deserialize)]
319pub struct FileExplorerState {
320 pub visible: bool,
321 #[serde(
326 alias = "width_percent",
327 default = "crate::config::default_explorer_width_value"
328 )]
329 pub width: crate::config::ExplorerWidth,
330 #[serde(default)]
332 pub side: crate::config::FileExplorerSide,
333 #[serde(default)]
335 pub expanded_dirs: Vec<PathBuf>,
336 #[serde(default)]
338 pub scroll_offset: usize,
339 #[serde(default)]
341 pub show_hidden: bool,
342 #[serde(default)]
344 pub show_gitignored: bool,
345}
346
347impl Default for FileExplorerState {
348 fn default() -> Self {
349 Self {
350 visible: false,
351 width: crate::config::default_explorer_width_value(),
352 side: crate::config::FileExplorerSide::Left,
353 expanded_dirs: Vec::new(),
354 scroll_offset: 0,
355 show_hidden: false,
356 show_gitignored: false,
357 }
358 }
359}
360
361#[derive(Debug, Clone, Default, Serialize, Deserialize)]
363pub struct WorkspaceHistories {
364 #[serde(default, skip_serializing_if = "Vec::is_empty")]
365 pub search: Vec<String>,
366 #[serde(default, skip_serializing_if = "Vec::is_empty")]
367 pub replace: Vec<String>,
368 #[serde(default, skip_serializing_if = "Vec::is_empty")]
369 pub command_palette: Vec<String>,
370 #[serde(default, skip_serializing_if = "Vec::is_empty")]
371 pub goto_line: Vec<String>,
372 #[serde(default, skip_serializing_if = "Vec::is_empty")]
373 pub open_file: Vec<String>,
374}
375
376#[derive(Debug, Clone, Default, Serialize, Deserialize)]
378pub struct SearchOptions {
379 #[serde(default)]
380 pub case_sensitive: bool,
381 #[serde(default)]
382 pub whole_word: bool,
383 #[serde(default)]
384 pub use_regex: bool,
385 #[serde(default)]
386 pub confirm_each: bool,
387}
388
389#[derive(Debug, Clone, Serialize, Deserialize)]
391pub struct SerializedBookmark {
392 pub file_path: PathBuf,
394 pub position: usize,
396}
397
398#[derive(Debug, Clone, Serialize, Deserialize)]
400pub enum SerializedTabRef {
401 File(PathBuf),
402 Terminal(usize),
403 Unnamed(String),
405}
406
407#[derive(Debug, Clone, Serialize, Deserialize)]
409pub struct SerializedTerminalWorkspace {
410 pub terminal_index: usize,
411 pub cwd: Option<PathBuf>,
412 pub shell: String,
413 pub cols: u16,
414 pub rows: u16,
415 pub log_path: PathBuf,
416 pub backing_path: PathBuf,
417}
418
419#[derive(Debug, Clone, Serialize, Deserialize)]
430pub struct PersistedFileState {
431 pub version: u32,
433
434 pub state: SerializedFileState,
436
437 pub saved_at: u64,
439}
440
441impl PersistedFileState {
442 fn new(state: SerializedFileState) -> Self {
443 Self {
444 version: FILE_WORKSPACE_VERSION,
445 state,
446 saved_at: SystemTime::now()
447 .duration_since(UNIX_EPOCH)
448 .unwrap_or_default()
449 .as_secs(),
450 }
451 }
452}
453
454pub struct PersistedFileWorkspace;
466
467impl PersistedFileWorkspace {
468 fn states_dir() -> io::Result<PathBuf> {
470 Ok(get_data_dir()?.join("file_states"))
471 }
472
473 fn state_file_path(source_path: &Path) -> io::Result<PathBuf> {
475 let canonical = source_path
476 .canonicalize()
477 .unwrap_or_else(|_| source_path.to_path_buf());
478 let filename = format!("{}.json", encode_path_for_filename(&canonical));
479 Ok(Self::states_dir()?.join(filename))
480 }
481
482 pub fn load(path: &Path) -> Option<SerializedFileState> {
484 let state_path = match Self::state_file_path(path) {
485 Ok(p) => p,
486 Err(_) => return None,
487 };
488
489 if !state_path.exists() {
490 return None;
491 }
492
493 let content = match std::fs::read_to_string(&state_path) {
494 Ok(c) => c,
495 Err(_) => return None,
496 };
497
498 let persisted: PersistedFileState = match serde_json::from_str(&content) {
499 Ok(p) => p,
500 Err(_) => return None,
501 };
502
503 if persisted.version > FILE_WORKSPACE_VERSION {
505 return None;
506 }
507
508 Some(persisted.state)
509 }
510
511 pub fn save(path: &Path, state: SerializedFileState) {
513 let state_path = match Self::state_file_path(path) {
514 Ok(p) => p,
515 Err(e) => {
516 tracing::warn!("Failed to get state path for {:?}: {}", path, e);
517 return;
518 }
519 };
520
521 if let Some(parent) = state_path.parent() {
523 if let Err(e) = std::fs::create_dir_all(parent) {
524 tracing::warn!("Failed to create state dir: {}", e);
525 return;
526 }
527 }
528
529 let persisted = PersistedFileState::new(state);
530 let content = match serde_json::to_string_pretty(&persisted) {
531 Ok(c) => c,
532 Err(e) => {
533 tracing::warn!("Failed to serialize file state: {}", e);
534 return;
535 }
536 };
537
538 let temp_path = state_path.with_extension("json.tmp");
540
541 let write_result = (|| -> io::Result<()> {
542 let mut file = std::fs::File::create(&temp_path)?;
543 file.write_all(content.as_bytes())?;
544 file.sync_all()?;
545 std::fs::rename(&temp_path, &state_path)?;
546 Ok(())
547 })();
548
549 if let Err(e) = write_result {
550 tracing::warn!("Failed to save file state for {:?}: {}", path, e);
551 } else {
552 tracing::trace!("File state saved for {:?}", path);
553 }
554 }
555}
556
557pub fn get_workspaces_dir() -> io::Result<PathBuf> {
563 Ok(get_data_dir()?.join("workspaces"))
564}
565
566pub fn encode_path_for_filename(path: &Path) -> String {
574 let path_str = path.to_string_lossy();
575 let mut result = String::with_capacity(path_str.len() * 2);
576
577 for c in path_str.chars() {
578 match c {
579 '/' | '\\' => result.push('_'),
581 c if c.is_ascii_alphanumeric() => result.push(c),
583 '-' | '.' => result.push(c),
584 '_' => result.push_str("%5F"),
586 c => {
588 for byte in c.to_string().as_bytes() {
589 result.push_str(&format!("%{:02X}", byte));
590 }
591 }
592 }
593 }
594
595 let result = result.trim_start_matches('_').to_string();
597
598 let mut final_result = String::with_capacity(result.len());
600 let mut last_was_underscore = false;
601 for c in result.chars() {
602 if c == '_' {
603 if !last_was_underscore {
604 final_result.push(c);
605 }
606 last_was_underscore = true;
607 } else {
608 final_result.push(c);
609 last_was_underscore = false;
610 }
611 }
612
613 if final_result.is_empty() {
614 final_result = "root".to_string();
615 }
616
617 final_result
618}
619
620#[allow(dead_code)]
622pub fn decode_filename_to_path(encoded: &str) -> Option<PathBuf> {
623 if encoded == "root" {
624 return Some(PathBuf::from("/"));
625 }
626
627 let mut result = String::with_capacity(encoded.len() + 1);
628 result.push('/');
630
631 let mut chars = encoded.chars().peekable();
632
633 while let Some(c) = chars.next() {
634 if c == '%' {
635 let hex: String = chars.by_ref().take(2).collect();
637 if hex.len() == 2 {
638 if let Ok(byte) = u8::from_str_radix(&hex, 16) {
639 result.push(byte as char);
640 }
641 }
642 } else if c == '_' {
643 result.push('/');
644 } else {
645 result.push(c);
646 }
647 }
648
649 Some(PathBuf::from(result))
650}
651
652pub fn get_workspace_path(working_dir: &Path) -> io::Result<PathBuf> {
654 let canonical = working_dir
655 .canonicalize()
656 .unwrap_or_else(|_| working_dir.to_path_buf());
657 let filename = format!("{}.json", encode_path_for_filename(&canonical));
658 Ok(get_workspaces_dir()?.join(filename))
659}
660
661pub fn get_session_workspaces_dir() -> io::Result<PathBuf> {
663 Ok(get_data_dir()?.join("session-workspaces"))
664}
665
666pub fn get_session_workspace_path(session_name: &str) -> io::Result<PathBuf> {
668 let dir = get_session_workspaces_dir()?;
669 std::fs::create_dir_all(&dir)?;
670 let safe_name: String = session_name
672 .chars()
673 .map(|c| {
674 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
675 c
676 } else {
677 '_'
678 }
679 })
680 .collect();
681 Ok(dir.join(format!("{}.json", safe_name)))
682}
683
684#[derive(Debug)]
686pub enum WorkspaceError {
687 Io(anyhow::Error),
688 Json(serde_json::Error),
689 WorkdirMismatch { expected: PathBuf, found: PathBuf },
690 VersionTooNew { version: u32, max_supported: u32 },
691}
692
693impl std::fmt::Display for WorkspaceError {
694 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
695 match self {
696 Self::Io(e) => write!(f, "Workspace error: {}", e),
697 Self::Json(e) => write!(f, "JSON error: {}", e),
698 Self::WorkdirMismatch { expected, found } => {
699 write!(
700 f,
701 "Working directory mismatch: expected {:?}, found {:?}",
702 expected, found
703 )
704 }
705 WorkspaceError::VersionTooNew {
706 version,
707 max_supported,
708 } => {
709 write!(
710 f,
711 "Workspace version {} is newer than supported (max: {})",
712 version, max_supported
713 )
714 }
715 }
716 }
717}
718
719impl std::error::Error for WorkspaceError {
720 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
721 match self {
722 Self::Io(e) => e.source(),
723 Self::Json(e) => Some(e),
724 _ => None,
725 }
726 }
727}
728
729impl From<io::Error> for WorkspaceError {
730 fn from(e: io::Error) -> Self {
731 WorkspaceError::Io(e.into())
732 }
733}
734
735impl From<anyhow::Error> for WorkspaceError {
736 fn from(e: anyhow::Error) -> Self {
737 WorkspaceError::Io(e)
738 }
739}
740
741impl From<serde_json::Error> for WorkspaceError {
742 fn from(e: serde_json::Error) -> Self {
743 WorkspaceError::Json(e)
744 }
745}
746
747impl Workspace {
748 pub fn load(working_dir: &Path) -> Result<Option<Workspace>, WorkspaceError> {
750 let path = get_workspace_path(working_dir)?;
751 tracing::debug!("Looking for workspace at {:?}", path);
752
753 if !path.exists() {
754 tracing::debug!("Workspace file does not exist");
755 return Ok(None);
756 }
757
758 tracing::debug!("Loading workspace from {:?}", path);
759 let content = std::fs::read_to_string(&path)?;
760 let workspace: Workspace = serde_json::from_str(&content)?;
761
762 tracing::debug!(
763 "Loaded workspace: version={}, split_states={}, active_split={}",
764 workspace.version,
765 workspace.split_states.len(),
766 workspace.active_split_id
767 );
768
769 let expected = working_dir
771 .canonicalize()
772 .unwrap_or_else(|_| working_dir.to_path_buf());
773 let found = workspace
774 .working_dir
775 .canonicalize()
776 .unwrap_or_else(|_| workspace.working_dir.clone());
777
778 if expected != found {
779 tracing::warn!(
780 "Workspace working_dir mismatch: expected {:?}, found {:?}",
781 expected,
782 found
783 );
784 return Err(WorkspaceError::WorkdirMismatch { expected, found });
785 }
786
787 if workspace.version > WORKSPACE_VERSION {
789 tracing::warn!(
790 "Workspace version {} is newer than supported {}",
791 workspace.version,
792 WORKSPACE_VERSION
793 );
794 return Err(WorkspaceError::VersionTooNew {
795 version: workspace.version,
796 max_supported: WORKSPACE_VERSION,
797 });
798 }
799
800 Ok(Some(workspace))
801 }
802
803 pub fn has_no_real_content(&self) -> bool {
812 self.terminals.is_empty()
813 && self.external_files.is_empty()
814 && self.unnamed_buffers.is_empty()
815 && self.split_states.values().all(|s| s.open_tabs.is_empty())
816 }
817
818 pub fn has_no_preservable_content(&self) -> bool {
825 self.external_files.is_empty()
826 && self.unnamed_buffers.is_empty()
827 && self.split_states.values().all(|s| {
828 s.open_tabs
829 .iter()
830 .all(|t| matches!(t, SerializedTabRef::Terminal(_)))
831 })
832 }
833
834 pub fn save(&self) -> Result<(), WorkspaceError> {
841 let path = get_workspace_path(&self.working_dir)?;
842 tracing::debug!("Saving workspace to {:?}", path);
843
844 if let Some(parent) = path.parent() {
846 std::fs::create_dir_all(parent)?;
847 }
848
849 let content = serde_json::to_string_pretty(self)?;
851 tracing::trace!("Workspace JSON size: {} bytes", content.len());
852
853 let temp_path = path.with_extension("json.tmp");
855
856 {
858 let mut file = std::fs::File::create(&temp_path)?;
859 file.write_all(content.as_bytes())?;
860 file.sync_all()?; }
862
863 std::fs::rename(&temp_path, &path)?;
865 tracing::info!("Workspace saved to {:?}", path);
866
867 Ok(())
868 }
869
870 pub fn load_session(
872 session_name: &str,
873 working_dir: &Path,
874 ) -> Result<Option<Workspace>, WorkspaceError> {
875 let path = get_session_workspace_path(session_name)?;
876 tracing::debug!("Looking for session workspace at {:?}", path);
877
878 if !path.exists() {
879 return Ok(None);
880 }
881
882 let content = std::fs::read_to_string(&path)?;
883 let workspace: Workspace = serde_json::from_str(&content)?;
884
885 if workspace.version > WORKSPACE_VERSION {
888 return Err(WorkspaceError::VersionTooNew {
889 version: workspace.version,
890 max_supported: WORKSPACE_VERSION,
891 });
892 }
893
894 let found = workspace
896 .working_dir
897 .canonicalize()
898 .unwrap_or_else(|_| workspace.working_dir.clone());
899 let expected = working_dir
900 .canonicalize()
901 .unwrap_or_else(|_| working_dir.to_path_buf());
902 if expected != found {
903 tracing::info!(
904 "Session '{}' workspace was saved from {:?}, now loading from {:?}",
905 session_name,
906 found,
907 expected
908 );
909 }
910
911 Ok(Some(workspace))
912 }
913
914 pub fn save_session(&self, session_name: &str) -> Result<(), WorkspaceError> {
916 let path = get_session_workspace_path(session_name)?;
917 tracing::debug!("Saving session workspace to {:?}", path);
918
919 if let Some(parent) = path.parent() {
920 std::fs::create_dir_all(parent)?;
921 }
922
923 let content = serde_json::to_string_pretty(self)?;
924 let temp_path = path.with_extension("json.tmp");
925 {
926 let mut file = std::fs::File::create(&temp_path)?;
927 file.write_all(content.as_bytes())?;
928 file.sync_all()?;
929 }
930 std::fs::rename(&temp_path, &path)?;
931 tracing::info!("Session workspace saved to {:?}", path);
932 Ok(())
933 }
934
935 pub fn delete(working_dir: &Path) -> Result<(), WorkspaceError> {
937 let path = get_workspace_path(working_dir)?;
938 if path.exists() {
939 std::fs::remove_file(path)?;
940 }
941 Ok(())
942 }
943
944 pub fn new(working_dir: PathBuf) -> Self {
946 Self {
947 version: WORKSPACE_VERSION,
948 working_dir,
949 split_layout: SerializedSplitNode::Leaf {
950 file_path: None,
951 split_id: 0,
952 label: None,
953 unnamed_recovery_id: None,
954 role: None,
955 },
956 active_split_id: 0,
957 split_states: HashMap::new(),
958 config_overrides: WorkspaceConfigOverrides::default(),
959 file_explorer: FileExplorerState::default(),
960 histories: WorkspaceHistories::default(),
961 search_options: SearchOptions::default(),
962 bookmarks: HashMap::new(),
963 terminals: Vec::new(),
964 external_files: Vec::new(),
965 read_only_files: Vec::new(),
966 unnamed_buffers: Vec::new(),
967 plugin_global_state: HashMap::new(),
968 saved_at: SystemTime::now()
969 .duration_since(UNIX_EPOCH)
970 .unwrap_or_default()
971 .as_secs(),
972 label: None,
973 session_plugin_state: HashMap::new(),
974 }
975 }
976
977 pub fn touch(&mut self) {
979 self.saved_at = SystemTime::now()
980 .duration_since(UNIX_EPOCH)
981 .unwrap_or_default()
982 .as_secs();
983 }
984}
985
986#[cfg(test)]
987mod tests {
988 use super::*;
989
990 #[test]
991 fn test_workspace_path_percent_encoding() {
992 let encoded = encode_path_for_filename(Path::new("/home/user/project"));
994 assert_eq!(encoded, "home_user_project");
995 assert!(!encoded.contains('/')); let decoded = decode_filename_to_path(&encoded).unwrap();
999 assert_eq!(decoded, PathBuf::from("/home/user/project"));
1000
1001 let path1 = get_workspace_path(Path::new("/home/user/project")).unwrap();
1003 let path2 = get_workspace_path(Path::new("/home/user/other")).unwrap();
1004 assert_ne!(path1, path2);
1005
1006 let path1_again = get_workspace_path(Path::new("/home/user/project")).unwrap();
1008 assert_eq!(path1, path1_again);
1009
1010 let filename = path1.file_name().unwrap().to_str().unwrap();
1012 assert!(filename.ends_with(".json"));
1013 assert!(filename.starts_with("home_user_project"));
1014 }
1015
1016 #[test]
1017 fn test_percent_encoding_edge_cases() {
1018 let encoded = encode_path_for_filename(Path::new("/home/user/my-project"));
1020 assert_eq!(encoded, "home_user_my-project");
1021
1022 let encoded = encode_path_for_filename(Path::new("/home/user/my project"));
1024 assert_eq!(encoded, "home_user_my%20project");
1025 let decoded = decode_filename_to_path(&encoded).unwrap();
1026 assert_eq!(decoded, PathBuf::from("/home/user/my project"));
1027
1028 let encoded = encode_path_for_filename(Path::new("/home/user/my_project"));
1030 assert_eq!(encoded, "home_user_my%5Fproject");
1031 let decoded = decode_filename_to_path(&encoded).unwrap();
1032 assert_eq!(decoded, PathBuf::from("/home/user/my_project"));
1033
1034 let encoded = encode_path_for_filename(Path::new("/"));
1036 assert_eq!(encoded, "root");
1037 }
1038
1039 #[test]
1040 fn test_workspace_serialization() {
1041 let workspace = Workspace::new(PathBuf::from("/home/user/test"));
1042 let json = serde_json::to_string(&workspace).unwrap();
1043 let restored: Workspace = serde_json::from_str(&json).unwrap();
1044
1045 assert_eq!(workspace.version, restored.version);
1046 assert_eq!(workspace.working_dir, restored.working_dir);
1047 }
1048
1049 #[test]
1050 fn test_workspace_config_overrides_skip_none() {
1051 let overrides = WorkspaceConfigOverrides::default();
1052 let json = serde_json::to_string(&overrides).unwrap();
1053
1054 assert_eq!(json, "{}");
1056 }
1057
1058 #[test]
1059 fn test_workspace_config_overrides_with_values() {
1060 let overrides = WorkspaceConfigOverrides {
1061 line_wrap: Some(false),
1062 ..Default::default()
1063 };
1064 let json = serde_json::to_string(&overrides).unwrap();
1065
1066 assert!(json.contains("line_wrap"));
1067 assert!(!json.contains("line_numbers")); }
1069
1070 #[test]
1071 fn test_split_layout_serialization() {
1072 let layout = SerializedSplitNode::Split {
1074 direction: SerializedSplitDirection::Vertical,
1075 first: Box::new(SerializedSplitNode::Leaf {
1076 file_path: Some(PathBuf::from("src/main.rs")),
1077 split_id: 1,
1078 label: None,
1079 unnamed_recovery_id: None,
1080 role: None,
1081 }),
1082 second: Box::new(SerializedSplitNode::Leaf {
1083 file_path: Some(PathBuf::from("src/lib.rs")),
1084 split_id: 2,
1085 label: None,
1086 unnamed_recovery_id: None,
1087 role: None,
1088 }),
1089 ratio: 0.5,
1090 split_id: 0,
1091 };
1092
1093 let json = serde_json::to_string(&layout).unwrap();
1094 let restored: SerializedSplitNode = serde_json::from_str(&json).unwrap();
1095
1096 match restored {
1098 SerializedSplitNode::Split {
1099 direction,
1100 ratio,
1101 split_id,
1102 ..
1103 } => {
1104 assert!(matches!(direction, SerializedSplitDirection::Vertical));
1105 assert_eq!(ratio, 0.5);
1106 assert_eq!(split_id, 0);
1107 }
1108 _ => panic!("Expected Split node"),
1109 }
1110 }
1111
1112 #[test]
1113 fn test_file_state_serialization() {
1114 let file_state = SerializedFileState {
1115 cursor: SerializedCursor {
1116 position: 1234,
1117 anchor: Some(1000),
1118 sticky_column: 15,
1119 },
1120 additional_cursors: vec![SerializedCursor {
1121 position: 5000,
1122 anchor: None,
1123 sticky_column: 0,
1124 }],
1125 scroll: SerializedScroll {
1126 top_byte: 500,
1127 top_view_line_offset: 2,
1128 left_column: 10,
1129 },
1130 view_mode: SerializedViewMode::Source,
1131 compose_width: None,
1132 plugin_state: HashMap::new(),
1133 folds: Vec::new(),
1134 };
1135
1136 let json = serde_json::to_string(&file_state).unwrap();
1137 let restored: SerializedFileState = serde_json::from_str(&json).unwrap();
1138
1139 assert_eq!(restored.cursor.position, 1234);
1140 assert_eq!(restored.cursor.anchor, Some(1000));
1141 assert_eq!(restored.cursor.sticky_column, 15);
1142 assert_eq!(restored.additional_cursors.len(), 1);
1143 assert_eq!(restored.scroll.top_byte, 500);
1144 assert_eq!(restored.scroll.left_column, 10);
1145 }
1146
1147 #[test]
1148 fn test_bookmark_serialization() {
1149 let mut bookmarks = HashMap::new();
1150 bookmarks.insert(
1151 'a',
1152 SerializedBookmark {
1153 file_path: PathBuf::from("src/main.rs"),
1154 position: 1234,
1155 },
1156 );
1157 bookmarks.insert(
1158 'b',
1159 SerializedBookmark {
1160 file_path: PathBuf::from("src/lib.rs"),
1161 position: 5678,
1162 },
1163 );
1164
1165 let json = serde_json::to_string(&bookmarks).unwrap();
1166 let restored: HashMap<char, SerializedBookmark> = serde_json::from_str(&json).unwrap();
1167
1168 assert_eq!(restored.len(), 2);
1169 assert_eq!(restored.get(&'a').unwrap().position, 1234);
1170 assert_eq!(
1171 restored.get(&'b').unwrap().file_path,
1172 PathBuf::from("src/lib.rs")
1173 );
1174 }
1175
1176 #[test]
1177 fn test_search_options_serialization() {
1178 let options = SearchOptions {
1179 case_sensitive: true,
1180 whole_word: true,
1181 use_regex: false,
1182 confirm_each: true,
1183 };
1184
1185 let json = serde_json::to_string(&options).unwrap();
1186 let restored: SearchOptions = serde_json::from_str(&json).unwrap();
1187
1188 assert!(restored.case_sensitive);
1189 assert!(restored.whole_word);
1190 assert!(!restored.use_regex);
1191 assert!(restored.confirm_each);
1192 }
1193
1194 #[test]
1195 fn test_full_workspace_round_trip() {
1196 let mut workspace = Workspace::new(PathBuf::from("/home/user/myproject"));
1197
1198 workspace.split_layout = SerializedSplitNode::Split {
1200 direction: SerializedSplitDirection::Horizontal,
1201 first: Box::new(SerializedSplitNode::Leaf {
1202 file_path: Some(PathBuf::from("README.md")),
1203 split_id: 1,
1204 label: None,
1205 unnamed_recovery_id: None,
1206 role: None,
1207 }),
1208 second: Box::new(SerializedSplitNode::Leaf {
1209 file_path: Some(PathBuf::from("Cargo.toml")),
1210 split_id: 2,
1211 label: None,
1212 unnamed_recovery_id: None,
1213 role: None,
1214 }),
1215 ratio: 0.6,
1216 split_id: 0,
1217 };
1218 workspace.active_split_id = 1;
1219
1220 workspace.split_states.insert(
1222 1,
1223 SerializedSplitViewState {
1224 open_tabs: vec![
1225 SerializedTabRef::File(PathBuf::from("README.md")),
1226 SerializedTabRef::File(PathBuf::from("src/lib.rs")),
1227 ],
1228 active_tab_index: Some(0),
1229 open_files: vec![PathBuf::from("README.md"), PathBuf::from("src/lib.rs")],
1230 active_file_index: 0,
1231 file_states: HashMap::new(),
1232 tab_scroll_offset: 0,
1233 view_mode: SerializedViewMode::Source,
1234 compose_width: None,
1235 },
1236 );
1237
1238 workspace.bookmarks.insert(
1240 'm',
1241 SerializedBookmark {
1242 file_path: PathBuf::from("src/main.rs"),
1243 position: 100,
1244 },
1245 );
1246
1247 workspace.search_options.case_sensitive = true;
1249 workspace.search_options.use_regex = true;
1250
1251 let json = serde_json::to_string_pretty(&workspace).unwrap();
1253 let restored: Workspace = serde_json::from_str(&json).unwrap();
1254
1255 assert_eq!(restored.version, WORKSPACE_VERSION);
1257 assert_eq!(restored.working_dir, PathBuf::from("/home/user/myproject"));
1258 assert_eq!(restored.active_split_id, 1);
1259 assert!(restored.bookmarks.contains_key(&'m'));
1260 assert!(restored.search_options.case_sensitive);
1261 assert!(restored.search_options.use_regex);
1262
1263 let split_state = restored.split_states.get(&1).unwrap();
1265 assert_eq!(split_state.open_files.len(), 2);
1266 assert_eq!(split_state.open_files[0], PathBuf::from("README.md"));
1267 }
1268
1269 #[test]
1270 fn test_workspace_file_save_load() {
1271 use std::fs;
1272
1273 let temp_dir = std::env::temp_dir().join("fresh_workspace_test");
1275 drop(fs::remove_dir_all(&temp_dir)); fs::create_dir_all(&temp_dir).unwrap();
1277
1278 let workspace_path = temp_dir.join("test_workspace.json");
1279
1280 let mut workspace = Workspace::new(temp_dir.clone());
1282 workspace.search_options.case_sensitive = true;
1283 workspace.bookmarks.insert(
1284 'x',
1285 SerializedBookmark {
1286 file_path: PathBuf::from("test.txt"),
1287 position: 42,
1288 },
1289 );
1290
1291 let content = serde_json::to_string_pretty(&workspace).unwrap();
1293 let temp_path = workspace_path.with_extension("json.tmp");
1294 let mut file = std::fs::File::create(&temp_path).unwrap();
1295 std::io::Write::write_all(&mut file, content.as_bytes()).unwrap();
1296 file.sync_all().unwrap();
1297 std::fs::rename(&temp_path, &workspace_path).unwrap();
1298
1299 let loaded_content = fs::read_to_string(&workspace_path).unwrap();
1301 let loaded: Workspace = serde_json::from_str(&loaded_content).unwrap();
1302
1303 assert_eq!(loaded.working_dir, temp_dir);
1305 assert!(loaded.search_options.case_sensitive);
1306 assert_eq!(loaded.bookmarks.get(&'x').unwrap().position, 42);
1307
1308 drop(fs::remove_dir_all(&temp_dir));
1310 }
1311
1312 #[test]
1313 fn test_workspace_version_check() {
1314 let workspace = Workspace::new(PathBuf::from("/test"));
1315 assert_eq!(workspace.version, WORKSPACE_VERSION);
1316
1317 let mut json_value: serde_json::Value = serde_json::to_value(&workspace).unwrap();
1319 json_value["version"] = serde_json::json!(999);
1320
1321 let json = serde_json::to_string(&json_value).unwrap();
1322 let restored: Workspace = serde_json::from_str(&json).unwrap();
1323
1324 assert_eq!(restored.version, 999);
1326 }
1327
1328 #[test]
1329 fn test_empty_workspace_histories() {
1330 let histories = WorkspaceHistories::default();
1331 let json = serde_json::to_string(&histories).unwrap();
1332
1333 assert_eq!(json, "{}");
1335
1336 let restored: WorkspaceHistories = serde_json::from_str(&json).unwrap();
1338 assert!(restored.search.is_empty());
1339 assert!(restored.replace.is_empty());
1340 }
1341
1342 #[test]
1343 fn test_file_explorer_state_percent_round_trip() {
1344 let state = FileExplorerState {
1345 visible: true,
1346 width: crate::config::ExplorerWidth::Percent(25),
1347 side: crate::config::FileExplorerSide::Left,
1348 expanded_dirs: vec![
1349 PathBuf::from("src"),
1350 PathBuf::from("src/app"),
1351 PathBuf::from("tests"),
1352 ],
1353 scroll_offset: 5,
1354 show_hidden: true,
1355 show_gitignored: false,
1356 };
1357
1358 let json = serde_json::to_string(&state).unwrap();
1359 let restored: FileExplorerState = serde_json::from_str(&json).unwrap();
1360
1361 assert!(restored.visible);
1362 assert_eq!(restored.width, crate::config::ExplorerWidth::Percent(25));
1363 assert_eq!(restored.expanded_dirs.len(), 3);
1364 assert_eq!(restored.scroll_offset, 5);
1365 assert!(restored.show_hidden);
1366 assert!(!restored.show_gitignored);
1367 }
1368
1369 #[test]
1370 fn test_file_explorer_state_columns_round_trip() {
1371 let state = FileExplorerState {
1372 visible: true,
1373 width: crate::config::ExplorerWidth::Columns(42),
1374 side: crate::config::FileExplorerSide::Left,
1375 expanded_dirs: vec![],
1376 scroll_offset: 0,
1377 show_hidden: false,
1378 show_gitignored: false,
1379 };
1380 let json = serde_json::to_string(&state).unwrap();
1381 let restored: FileExplorerState = serde_json::from_str(&json).unwrap();
1382 assert_eq!(restored.width, crate::config::ExplorerWidth::Columns(42));
1383 }
1384
1385 #[test]
1390 fn test_file_explorer_state_legacy_width_percent_alias() {
1391 let json = r#"{
1392 "visible": true,
1393 "width_percent": 0.3,
1394 "expanded_dirs": [],
1395 "scroll_offset": 0,
1396 "show_hidden": false,
1397 "show_gitignored": false
1398 }"#;
1399 let restored: FileExplorerState = serde_json::from_str(json).unwrap();
1400 assert_eq!(restored.width, crate::config::ExplorerWidth::Percent(30));
1401 }
1402}