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