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 #[serde(default, skip_serializing_if = "is_local_authority_spec")]
126 pub authority_spec: crate::services::authority::SessionAuthoritySpec,
127}
128
129fn is_local_authority_spec(spec: &crate::services::authority::SessionAuthoritySpec) -> bool {
132 matches!(
133 spec,
134 crate::services::authority::SessionAuthoritySpec::Local
135 )
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
140pub struct UnnamedBufferRef {
141 pub recovery_id: String,
143 pub display_name: String,
145}
146
147#[derive(Debug, Clone, Serialize, Deserialize)]
149pub enum SerializedSplitNode {
150 Leaf {
151 file_path: Option<PathBuf>,
153 split_id: usize,
154 #[serde(default, skip_serializing_if = "Option::is_none")]
156 label: Option<String>,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
159 unnamed_recovery_id: Option<String>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
162 role: Option<crate::view::split::SplitRole>,
163 },
164 Terminal {
165 terminal_index: usize,
166 split_id: usize,
167 #[serde(default, skip_serializing_if = "Option::is_none")]
169 label: Option<String>,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 role: Option<crate::view::split::SplitRole>,
173 },
174 Split {
175 direction: SerializedSplitDirection,
176 first: Box<Self>,
177 second: Box<Self>,
178 ratio: f32,
179 split_id: usize,
180 },
181}
182
183#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
184pub enum SerializedSplitDirection {
185 Horizontal,
186 Vertical,
187}
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct SerializedSplitViewState {
192 #[serde(default)]
194 pub open_tabs: Vec<SerializedTabRef>,
195
196 #[serde(default)]
198 pub active_tab_index: Option<usize>,
199
200 #[serde(default)]
203 pub open_files: Vec<PathBuf>,
204
205 #[serde(default)]
207 pub active_file_index: usize,
208
209 #[serde(default)]
211 pub file_states: HashMap<PathBuf, SerializedFileState>,
212
213 #[serde(default)]
215 pub tab_scroll_offset: usize,
216
217 #[serde(default)]
219 pub view_mode: SerializedViewMode,
220
221 #[serde(default)]
223 pub compose_width: Option<u16>,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
228pub struct SerializedFileState {
229 pub cursor: SerializedCursor,
231
232 #[serde(default)]
234 pub additional_cursors: Vec<SerializedCursor>,
235
236 pub scroll: SerializedScroll,
238
239 #[serde(default)]
241 pub view_mode: SerializedViewMode,
242
243 #[serde(default)]
245 pub compose_width: Option<u16>,
246
247 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
249 pub plugin_state: HashMap<String, serde_json::Value>,
250
251 #[serde(default, skip_serializing_if = "Vec::is_empty")]
253 pub folds: Vec<SerializedFoldRange>,
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize)]
258pub struct SerializedFoldRange {
259 pub header_line: usize,
261 pub end_line: usize,
263 #[serde(default)]
265 pub placeholder: Option<String>,
266 #[serde(default)]
275 pub header_text: Option<String>,
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct SerializedCursor {
280 pub position: usize,
282 #[serde(default)]
284 pub anchor: Option<usize>,
285 #[serde(default)]
287 pub sticky_column: usize,
288}
289
290#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct SerializedScroll {
292 pub top_byte: usize,
294 #[serde(default)]
296 pub top_view_line_offset: usize,
297 #[serde(default)]
299 pub left_column: usize,
300}
301
302#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
303pub enum SerializedViewMode {
304 #[default]
305 Source,
306 #[serde(alias = "Compose")]
309 PageView,
310}
311
312#[derive(Debug, Clone, Default, Serialize, Deserialize)]
314pub struct WorkspaceConfigOverrides {
315 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub line_numbers: Option<bool>,
317 #[serde(default, skip_serializing_if = "Option::is_none")]
318 pub relative_line_numbers: Option<bool>,
319 #[serde(default, skip_serializing_if = "Option::is_none")]
320 pub line_wrap: Option<bool>,
321 #[serde(default, skip_serializing_if = "Option::is_none")]
322 pub syntax_highlighting: Option<bool>,
323 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub enable_inlay_hints: Option<bool>,
325 #[serde(default, skip_serializing_if = "Option::is_none")]
326 pub mouse_enabled: Option<bool>,
327 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub menu_bar_hidden: Option<bool>,
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize)]
337pub struct FileExplorerState {
338 pub visible: bool,
339 #[serde(
344 alias = "width_percent",
345 default = "crate::config::default_explorer_width_value"
346 )]
347 pub width: crate::config::ExplorerWidth,
348 #[serde(default)]
350 pub side: crate::config::FileExplorerSide,
351 #[serde(default)]
353 pub expanded_dirs: Vec<PathBuf>,
354 #[serde(default)]
356 pub scroll_offset: usize,
357 #[serde(default)]
359 pub show_hidden: bool,
360 #[serde(default)]
362 pub show_gitignored: bool,
363}
364
365impl Default for FileExplorerState {
366 fn default() -> Self {
367 Self {
368 visible: false,
369 width: crate::config::default_explorer_width_value(),
370 side: crate::config::FileExplorerSide::Left,
371 expanded_dirs: Vec::new(),
372 scroll_offset: 0,
373 show_hidden: false,
374 show_gitignored: false,
375 }
376 }
377}
378
379#[derive(Debug, Clone, Default, Serialize, Deserialize)]
381pub struct WorkspaceHistories {
382 #[serde(default, skip_serializing_if = "Vec::is_empty")]
383 pub search: Vec<String>,
384 #[serde(default, skip_serializing_if = "Vec::is_empty")]
385 pub replace: Vec<String>,
386 #[serde(default, skip_serializing_if = "Vec::is_empty")]
387 pub command_palette: Vec<String>,
388 #[serde(default, skip_serializing_if = "Vec::is_empty")]
389 pub goto_line: Vec<String>,
390 #[serde(default, skip_serializing_if = "Vec::is_empty")]
391 pub open_file: Vec<String>,
392}
393
394#[derive(Debug, Clone, Default, Serialize, Deserialize)]
396pub struct SearchOptions {
397 #[serde(default)]
398 pub case_sensitive: bool,
399 #[serde(default)]
400 pub whole_word: bool,
401 #[serde(default)]
402 pub use_regex: bool,
403 #[serde(default)]
404 pub confirm_each: bool,
405}
406
407#[derive(Debug, Clone, Serialize, Deserialize)]
409pub struct SerializedBookmark {
410 pub file_path: PathBuf,
412 pub position: usize,
414}
415
416#[derive(Debug, Clone, Serialize, Deserialize)]
418pub enum SerializedTabRef {
419 File(PathBuf),
420 Terminal(usize),
421 Unnamed(String),
423}
424
425#[derive(Debug, Clone, Serialize, Deserialize)]
427pub struct SerializedTerminalWorkspace {
428 pub terminal_index: usize,
429 pub cwd: Option<PathBuf>,
430 pub shell: String,
431 pub cols: u16,
432 pub rows: u16,
433 pub log_path: PathBuf,
434 pub backing_path: PathBuf,
435 #[serde(default, skip_serializing_if = "Option::is_none")]
441 pub command: Option<Vec<String>>,
442 #[serde(default, skip_serializing_if = "Option::is_none")]
450 pub agent_resume: Option<AgentResume>,
451}
452
453#[derive(Debug, Clone, Serialize, Deserialize)]
458pub struct AgentResume {
459 pub argv: Vec<String>,
463}
464
465#[derive(Debug, Clone, Serialize, Deserialize)]
476pub struct PersistedFileState {
477 pub version: u32,
479
480 pub state: SerializedFileState,
482
483 pub saved_at: u64,
485}
486
487impl PersistedFileState {
488 fn new(state: SerializedFileState) -> Self {
489 Self {
490 version: FILE_WORKSPACE_VERSION,
491 state,
492 saved_at: SystemTime::now()
493 .duration_since(UNIX_EPOCH)
494 .unwrap_or_default()
495 .as_secs(),
496 }
497 }
498}
499
500pub struct PersistedFileWorkspace;
512
513impl PersistedFileWorkspace {
514 fn states_dir() -> io::Result<PathBuf> {
516 Ok(get_data_dir()?.join("file_states"))
517 }
518
519 fn state_file_path(source_path: &Path) -> io::Result<PathBuf> {
521 let canonical = source_path
522 .canonicalize()
523 .unwrap_or_else(|_| source_path.to_path_buf());
524 let filename = format!("{}.json", encode_path_for_filename(&canonical));
525 Ok(Self::states_dir()?.join(filename))
526 }
527
528 pub fn load(path: &Path) -> Option<SerializedFileState> {
530 let state_path = match Self::state_file_path(path) {
531 Ok(p) => p,
532 Err(_) => return None,
533 };
534
535 if !state_path.exists() {
536 return None;
537 }
538
539 let content = match std::fs::read_to_string(&state_path) {
540 Ok(c) => c,
541 Err(_) => return None,
542 };
543
544 let persisted: PersistedFileState = match serde_json::from_str(&content) {
545 Ok(p) => p,
546 Err(_) => return None,
547 };
548
549 if persisted.version > FILE_WORKSPACE_VERSION {
551 return None;
552 }
553
554 Some(persisted.state)
555 }
556
557 pub fn save(path: &Path, state: SerializedFileState) {
559 let state_path = match Self::state_file_path(path) {
560 Ok(p) => p,
561 Err(e) => {
562 tracing::warn!("Failed to get state path for {:?}: {}", path, e);
563 return;
564 }
565 };
566
567 if let Some(parent) = state_path.parent() {
569 if let Err(e) = std::fs::create_dir_all(parent) {
570 tracing::warn!("Failed to create state dir: {}", e);
571 return;
572 }
573 }
574
575 let persisted = PersistedFileState::new(state);
576 let content = match serde_json::to_string_pretty(&persisted) {
577 Ok(c) => c,
578 Err(e) => {
579 tracing::warn!("Failed to serialize file state: {}", e);
580 return;
581 }
582 };
583
584 let temp_path = state_path.with_extension("json.tmp");
586
587 let write_result = (|| -> io::Result<()> {
588 let mut file = std::fs::File::create(&temp_path)?;
589 file.write_all(content.as_bytes())?;
590 file.sync_all()?;
591 std::fs::rename(&temp_path, &state_path)?;
592 Ok(())
593 })();
594
595 if let Err(e) = write_result {
596 tracing::warn!("Failed to save file state for {:?}: {}", path, e);
597 } else {
598 tracing::trace!("File state saved for {:?}", path);
599 }
600 }
601}
602
603pub fn get_workspaces_dir() -> io::Result<PathBuf> {
609 Ok(get_data_dir()?.join("workspaces"))
610}
611
612pub fn encode_path_for_filename(path: &Path) -> String {
620 let path_str = path.to_string_lossy();
621 let mut result = String::with_capacity(path_str.len() * 2);
622
623 for c in path_str.chars() {
624 match c {
625 '/' | '\\' => result.push('_'),
627 c if c.is_ascii_alphanumeric() => result.push(c),
629 '-' | '.' => result.push(c),
630 '_' => result.push_str("%5F"),
632 c => {
634 for byte in c.to_string().as_bytes() {
635 result.push_str(&format!("%{:02X}", byte));
636 }
637 }
638 }
639 }
640
641 let result = result.trim_start_matches('_').to_string();
643
644 let mut final_result = String::with_capacity(result.len());
646 let mut last_was_underscore = false;
647 for c in result.chars() {
648 if c == '_' {
649 if !last_was_underscore {
650 final_result.push(c);
651 }
652 last_was_underscore = true;
653 } else {
654 final_result.push(c);
655 last_was_underscore = false;
656 }
657 }
658
659 if final_result.is_empty() {
660 final_result = "root".to_string();
661 }
662
663 final_result
664}
665
666#[allow(dead_code)]
668pub fn decode_filename_to_path(encoded: &str) -> Option<PathBuf> {
669 if encoded == "root" {
670 return Some(PathBuf::from("/"));
671 }
672
673 let mut result = String::with_capacity(encoded.len() + 1);
674 result.push('/');
676
677 let mut chars = encoded.chars().peekable();
678
679 while let Some(c) = chars.next() {
680 if c == '%' {
681 let hex: String = chars.by_ref().take(2).collect();
683 if hex.len() == 2 {
684 if let Ok(byte) = u8::from_str_radix(&hex, 16) {
685 result.push(byte as char);
686 }
687 }
688 } else if c == '_' {
689 result.push('/');
690 } else {
691 result.push(c);
692 }
693 }
694
695 Some(PathBuf::from(result))
696}
697
698pub fn get_workspace_path(working_dir: &Path) -> io::Result<PathBuf> {
700 let canonical = working_dir
701 .canonicalize()
702 .unwrap_or_else(|_| working_dir.to_path_buf());
703 let filename = format!("{}.json", encode_path_for_filename(&canonical));
704 Ok(get_workspaces_dir()?.join(filename))
705}
706
707pub fn get_session_workspaces_dir() -> io::Result<PathBuf> {
709 Ok(get_data_dir()?.join("session-workspaces"))
710}
711
712pub fn get_session_workspace_path(session_name: &str) -> io::Result<PathBuf> {
714 let dir = get_session_workspaces_dir()?;
715 std::fs::create_dir_all(&dir)?;
716 let safe_name: String = session_name
718 .chars()
719 .map(|c| {
720 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
721 c
722 } else {
723 '_'
724 }
725 })
726 .collect();
727 Ok(dir.join(format!("{}.json", safe_name)))
728}
729
730#[derive(Debug)]
732pub enum WorkspaceError {
733 Io(anyhow::Error),
734 Json(serde_json::Error),
735 WorkdirMismatch { expected: PathBuf, found: PathBuf },
736 VersionTooNew { version: u32, max_supported: u32 },
737}
738
739impl std::fmt::Display for WorkspaceError {
740 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
741 match self {
742 Self::Io(e) => write!(f, "Workspace error: {}", e),
743 Self::Json(e) => write!(f, "JSON error: {}", e),
744 Self::WorkdirMismatch { expected, found } => {
745 write!(
746 f,
747 "Working directory mismatch: expected {:?}, found {:?}",
748 expected, found
749 )
750 }
751 WorkspaceError::VersionTooNew {
752 version,
753 max_supported,
754 } => {
755 write!(
756 f,
757 "Workspace version {} is newer than supported (max: {})",
758 version, max_supported
759 )
760 }
761 }
762 }
763}
764
765impl std::error::Error for WorkspaceError {
766 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
767 match self {
768 Self::Io(e) => e.source(),
769 Self::Json(e) => Some(e),
770 _ => None,
771 }
772 }
773}
774
775impl From<io::Error> for WorkspaceError {
776 fn from(e: io::Error) -> Self {
777 WorkspaceError::Io(e.into())
778 }
779}
780
781impl From<anyhow::Error> for WorkspaceError {
782 fn from(e: anyhow::Error) -> Self {
783 WorkspaceError::Io(e)
784 }
785}
786
787impl From<serde_json::Error> for WorkspaceError {
788 fn from(e: serde_json::Error) -> Self {
789 WorkspaceError::Json(e)
790 }
791}
792
793impl Workspace {
794 pub fn load(working_dir: &Path) -> Result<Option<Workspace>, WorkspaceError> {
796 let path = get_workspace_path(working_dir)?;
797 tracing::debug!("Looking for workspace at {:?}", path);
798
799 if !path.exists() {
800 tracing::debug!("Workspace file does not exist");
801 return Ok(None);
802 }
803
804 tracing::debug!("Loading workspace from {:?}", path);
805 let content = std::fs::read_to_string(&path)?;
806 let workspace: Workspace = serde_json::from_str(&content)?;
807
808 tracing::debug!(
809 "Loaded workspace: version={}, split_states={}, active_split={}",
810 workspace.version,
811 workspace.split_states.len(),
812 workspace.active_split_id
813 );
814
815 let expected = working_dir
817 .canonicalize()
818 .unwrap_or_else(|_| working_dir.to_path_buf());
819 let found = workspace
820 .working_dir
821 .canonicalize()
822 .unwrap_or_else(|_| workspace.working_dir.clone());
823
824 if expected != found {
825 tracing::warn!(
826 "Workspace working_dir mismatch: expected {:?}, found {:?}",
827 expected,
828 found
829 );
830 return Err(WorkspaceError::WorkdirMismatch { expected, found });
831 }
832
833 if workspace.version > WORKSPACE_VERSION {
835 tracing::warn!(
836 "Workspace version {} is newer than supported {}",
837 workspace.version,
838 WORKSPACE_VERSION
839 );
840 return Err(WorkspaceError::VersionTooNew {
841 version: workspace.version,
842 max_supported: WORKSPACE_VERSION,
843 });
844 }
845
846 Ok(Some(workspace))
847 }
848
849 pub fn has_no_real_content(&self) -> bool {
858 self.terminals.is_empty()
859 && self.external_files.is_empty()
860 && self.unnamed_buffers.is_empty()
861 && self.split_states.values().all(|s| s.open_tabs.is_empty())
862 }
863
864 pub fn has_no_preservable_content(&self) -> bool {
871 self.external_files.is_empty()
872 && self.unnamed_buffers.is_empty()
873 && self.split_states.values().all(|s| {
874 s.open_tabs
875 .iter()
876 .all(|t| matches!(t, SerializedTabRef::Terminal(_)))
877 })
878 }
879
880 pub fn save(&self) -> Result<(), WorkspaceError> {
887 let path = get_workspace_path(&self.working_dir)?;
888 tracing::debug!("Saving workspace to {:?}", path);
889
890 if let Some(parent) = path.parent() {
892 std::fs::create_dir_all(parent)?;
893 }
894
895 let content = serde_json::to_string_pretty(self)?;
897 tracing::trace!("Workspace JSON size: {} bytes", content.len());
898
899 let temp_path = path.with_extension("json.tmp");
901
902 {
904 let mut file = std::fs::File::create(&temp_path)?;
905 file.write_all(content.as_bytes())?;
906 file.sync_all()?; }
908
909 std::fs::rename(&temp_path, &path)?;
911 tracing::info!("Workspace saved to {:?}", path);
912
913 Ok(())
914 }
915
916 pub fn load_session(
918 session_name: &str,
919 working_dir: &Path,
920 ) -> Result<Option<Workspace>, WorkspaceError> {
921 let path = get_session_workspace_path(session_name)?;
922 tracing::debug!("Looking for session workspace at {:?}", path);
923
924 if !path.exists() {
925 return Ok(None);
926 }
927
928 let content = std::fs::read_to_string(&path)?;
929 let workspace: Workspace = serde_json::from_str(&content)?;
930
931 if workspace.version > WORKSPACE_VERSION {
934 return Err(WorkspaceError::VersionTooNew {
935 version: workspace.version,
936 max_supported: WORKSPACE_VERSION,
937 });
938 }
939
940 let found = workspace
942 .working_dir
943 .canonicalize()
944 .unwrap_or_else(|_| workspace.working_dir.clone());
945 let expected = working_dir
946 .canonicalize()
947 .unwrap_or_else(|_| working_dir.to_path_buf());
948 if expected != found {
949 tracing::info!(
950 "Session '{}' workspace was saved from {:?}, now loading from {:?}",
951 session_name,
952 found,
953 expected
954 );
955 }
956
957 Ok(Some(workspace))
958 }
959
960 pub fn save_session(&self, session_name: &str) -> Result<(), WorkspaceError> {
962 let path = get_session_workspace_path(session_name)?;
963 tracing::debug!("Saving session workspace to {:?}", path);
964
965 if let Some(parent) = path.parent() {
966 std::fs::create_dir_all(parent)?;
967 }
968
969 let content = serde_json::to_string_pretty(self)?;
970 let temp_path = path.with_extension("json.tmp");
971 {
972 let mut file = std::fs::File::create(&temp_path)?;
973 file.write_all(content.as_bytes())?;
974 file.sync_all()?;
975 }
976 std::fs::rename(&temp_path, &path)?;
977 tracing::info!("Session workspace saved to {:?}", path);
978 Ok(())
979 }
980
981 pub fn delete(working_dir: &Path) -> Result<(), WorkspaceError> {
983 let path = get_workspace_path(working_dir)?;
984 if path.exists() {
985 std::fs::remove_file(path)?;
986 }
987 Ok(())
988 }
989
990 pub fn new(working_dir: PathBuf) -> Self {
992 Self {
993 version: WORKSPACE_VERSION,
994 working_dir,
995 split_layout: SerializedSplitNode::Leaf {
996 file_path: None,
997 split_id: 0,
998 label: None,
999 unnamed_recovery_id: None,
1000 role: None,
1001 },
1002 active_split_id: 0,
1003 split_states: HashMap::new(),
1004 config_overrides: WorkspaceConfigOverrides::default(),
1005 file_explorer: FileExplorerState::default(),
1006 histories: WorkspaceHistories::default(),
1007 search_options: SearchOptions::default(),
1008 bookmarks: HashMap::new(),
1009 terminals: Vec::new(),
1010 external_files: Vec::new(),
1011 read_only_files: Vec::new(),
1012 unnamed_buffers: Vec::new(),
1013 plugin_global_state: HashMap::new(),
1014 saved_at: SystemTime::now()
1015 .duration_since(UNIX_EPOCH)
1016 .unwrap_or_default()
1017 .as_secs(),
1018 label: None,
1019 session_plugin_state: HashMap::new(),
1020 authority_spec: crate::services::authority::SessionAuthoritySpec::Local,
1021 }
1022 }
1023
1024 pub fn touch(&mut self) {
1026 self.saved_at = SystemTime::now()
1027 .duration_since(UNIX_EPOCH)
1028 .unwrap_or_default()
1029 .as_secs();
1030 }
1031}
1032
1033#[cfg(test)]
1034mod tests {
1035 use super::*;
1036
1037 #[test]
1038 fn test_workspace_path_percent_encoding() {
1039 let encoded = encode_path_for_filename(Path::new("/home/user/project"));
1041 assert_eq!(encoded, "home_user_project");
1042 assert!(!encoded.contains('/')); let decoded = decode_filename_to_path(&encoded).unwrap();
1046 assert_eq!(decoded, PathBuf::from("/home/user/project"));
1047
1048 let path1 = get_workspace_path(Path::new("/home/user/project")).unwrap();
1050 let path2 = get_workspace_path(Path::new("/home/user/other")).unwrap();
1051 assert_ne!(path1, path2);
1052
1053 let path1_again = get_workspace_path(Path::new("/home/user/project")).unwrap();
1055 assert_eq!(path1, path1_again);
1056
1057 let filename = path1.file_name().unwrap().to_str().unwrap();
1059 assert!(filename.ends_with(".json"));
1060 assert!(filename.starts_with("home_user_project"));
1061 }
1062
1063 #[test]
1064 fn test_percent_encoding_edge_cases() {
1065 let encoded = encode_path_for_filename(Path::new("/home/user/my-project"));
1067 assert_eq!(encoded, "home_user_my-project");
1068
1069 let encoded = encode_path_for_filename(Path::new("/home/user/my project"));
1071 assert_eq!(encoded, "home_user_my%20project");
1072 let decoded = decode_filename_to_path(&encoded).unwrap();
1073 assert_eq!(decoded, PathBuf::from("/home/user/my project"));
1074
1075 let encoded = encode_path_for_filename(Path::new("/home/user/my_project"));
1077 assert_eq!(encoded, "home_user_my%5Fproject");
1078 let decoded = decode_filename_to_path(&encoded).unwrap();
1079 assert_eq!(decoded, PathBuf::from("/home/user/my_project"));
1080
1081 let encoded = encode_path_for_filename(Path::new("/"));
1083 assert_eq!(encoded, "root");
1084 }
1085
1086 #[test]
1087 fn test_workspace_serialization() {
1088 let workspace = Workspace::new(PathBuf::from("/home/user/test"));
1089 let json = serde_json::to_string(&workspace).unwrap();
1090 let restored: Workspace = serde_json::from_str(&json).unwrap();
1091
1092 assert_eq!(workspace.version, restored.version);
1093 assert_eq!(workspace.working_dir, restored.working_dir);
1094 }
1095
1096 #[test]
1097 fn test_workspace_config_overrides_skip_none() {
1098 let overrides = WorkspaceConfigOverrides::default();
1099 let json = serde_json::to_string(&overrides).unwrap();
1100
1101 assert_eq!(json, "{}");
1103 }
1104
1105 #[test]
1106 fn test_workspace_config_overrides_with_values() {
1107 let overrides = WorkspaceConfigOverrides {
1108 line_wrap: Some(false),
1109 ..Default::default()
1110 };
1111 let json = serde_json::to_string(&overrides).unwrap();
1112
1113 assert!(json.contains("line_wrap"));
1114 assert!(!json.contains("line_numbers")); }
1116
1117 #[test]
1118 fn test_split_layout_serialization() {
1119 let layout = SerializedSplitNode::Split {
1121 direction: SerializedSplitDirection::Vertical,
1122 first: Box::new(SerializedSplitNode::Leaf {
1123 file_path: Some(PathBuf::from("src/main.rs")),
1124 split_id: 1,
1125 label: None,
1126 unnamed_recovery_id: None,
1127 role: None,
1128 }),
1129 second: Box::new(SerializedSplitNode::Leaf {
1130 file_path: Some(PathBuf::from("src/lib.rs")),
1131 split_id: 2,
1132 label: None,
1133 unnamed_recovery_id: None,
1134 role: None,
1135 }),
1136 ratio: 0.5,
1137 split_id: 0,
1138 };
1139
1140 let json = serde_json::to_string(&layout).unwrap();
1141 let restored: SerializedSplitNode = serde_json::from_str(&json).unwrap();
1142
1143 match restored {
1145 SerializedSplitNode::Split {
1146 direction,
1147 ratio,
1148 split_id,
1149 ..
1150 } => {
1151 assert!(matches!(direction, SerializedSplitDirection::Vertical));
1152 assert_eq!(ratio, 0.5);
1153 assert_eq!(split_id, 0);
1154 }
1155 _ => panic!("Expected Split node"),
1156 }
1157 }
1158
1159 #[test]
1160 fn test_file_state_serialization() {
1161 let file_state = SerializedFileState {
1162 cursor: SerializedCursor {
1163 position: 1234,
1164 anchor: Some(1000),
1165 sticky_column: 15,
1166 },
1167 additional_cursors: vec![SerializedCursor {
1168 position: 5000,
1169 anchor: None,
1170 sticky_column: 0,
1171 }],
1172 scroll: SerializedScroll {
1173 top_byte: 500,
1174 top_view_line_offset: 2,
1175 left_column: 10,
1176 },
1177 view_mode: SerializedViewMode::Source,
1178 compose_width: None,
1179 plugin_state: HashMap::new(),
1180 folds: Vec::new(),
1181 };
1182
1183 let json = serde_json::to_string(&file_state).unwrap();
1184 let restored: SerializedFileState = serde_json::from_str(&json).unwrap();
1185
1186 assert_eq!(restored.cursor.position, 1234);
1187 assert_eq!(restored.cursor.anchor, Some(1000));
1188 assert_eq!(restored.cursor.sticky_column, 15);
1189 assert_eq!(restored.additional_cursors.len(), 1);
1190 assert_eq!(restored.scroll.top_byte, 500);
1191 assert_eq!(restored.scroll.left_column, 10);
1192 }
1193
1194 #[test]
1195 fn test_bookmark_serialization() {
1196 let mut bookmarks = HashMap::new();
1197 bookmarks.insert(
1198 'a',
1199 SerializedBookmark {
1200 file_path: PathBuf::from("src/main.rs"),
1201 position: 1234,
1202 },
1203 );
1204 bookmarks.insert(
1205 'b',
1206 SerializedBookmark {
1207 file_path: PathBuf::from("src/lib.rs"),
1208 position: 5678,
1209 },
1210 );
1211
1212 let json = serde_json::to_string(&bookmarks).unwrap();
1213 let restored: HashMap<char, SerializedBookmark> = serde_json::from_str(&json).unwrap();
1214
1215 assert_eq!(restored.len(), 2);
1216 assert_eq!(restored.get(&'a').unwrap().position, 1234);
1217 assert_eq!(
1218 restored.get(&'b').unwrap().file_path,
1219 PathBuf::from("src/lib.rs")
1220 );
1221 }
1222
1223 #[test]
1224 fn test_search_options_serialization() {
1225 let options = SearchOptions {
1226 case_sensitive: true,
1227 whole_word: true,
1228 use_regex: false,
1229 confirm_each: true,
1230 };
1231
1232 let json = serde_json::to_string(&options).unwrap();
1233 let restored: SearchOptions = serde_json::from_str(&json).unwrap();
1234
1235 assert!(restored.case_sensitive);
1236 assert!(restored.whole_word);
1237 assert!(!restored.use_regex);
1238 assert!(restored.confirm_each);
1239 }
1240
1241 #[test]
1242 fn test_full_workspace_round_trip() {
1243 let mut workspace = Workspace::new(PathBuf::from("/home/user/myproject"));
1244
1245 workspace.split_layout = SerializedSplitNode::Split {
1247 direction: SerializedSplitDirection::Horizontal,
1248 first: Box::new(SerializedSplitNode::Leaf {
1249 file_path: Some(PathBuf::from("README.md")),
1250 split_id: 1,
1251 label: None,
1252 unnamed_recovery_id: None,
1253 role: None,
1254 }),
1255 second: Box::new(SerializedSplitNode::Leaf {
1256 file_path: Some(PathBuf::from("Cargo.toml")),
1257 split_id: 2,
1258 label: None,
1259 unnamed_recovery_id: None,
1260 role: None,
1261 }),
1262 ratio: 0.6,
1263 split_id: 0,
1264 };
1265 workspace.active_split_id = 1;
1266
1267 workspace.split_states.insert(
1269 1,
1270 SerializedSplitViewState {
1271 open_tabs: vec![
1272 SerializedTabRef::File(PathBuf::from("README.md")),
1273 SerializedTabRef::File(PathBuf::from("src/lib.rs")),
1274 ],
1275 active_tab_index: Some(0),
1276 open_files: vec![PathBuf::from("README.md"), PathBuf::from("src/lib.rs")],
1277 active_file_index: 0,
1278 file_states: HashMap::new(),
1279 tab_scroll_offset: 0,
1280 view_mode: SerializedViewMode::Source,
1281 compose_width: None,
1282 },
1283 );
1284
1285 workspace.bookmarks.insert(
1287 'm',
1288 SerializedBookmark {
1289 file_path: PathBuf::from("src/main.rs"),
1290 position: 100,
1291 },
1292 );
1293
1294 workspace.search_options.case_sensitive = true;
1296 workspace.search_options.use_regex = true;
1297
1298 let json = serde_json::to_string_pretty(&workspace).unwrap();
1300 let restored: Workspace = serde_json::from_str(&json).unwrap();
1301
1302 assert_eq!(restored.version, WORKSPACE_VERSION);
1304 assert_eq!(restored.working_dir, PathBuf::from("/home/user/myproject"));
1305 assert_eq!(restored.active_split_id, 1);
1306 assert!(restored.bookmarks.contains_key(&'m'));
1307 assert!(restored.search_options.case_sensitive);
1308 assert!(restored.search_options.use_regex);
1309
1310 let split_state = restored.split_states.get(&1).unwrap();
1312 assert_eq!(split_state.open_files.len(), 2);
1313 assert_eq!(split_state.open_files[0], PathBuf::from("README.md"));
1314 }
1315
1316 #[test]
1317 fn test_workspace_file_save_load() {
1318 use std::fs;
1319
1320 let temp_dir = std::env::temp_dir().join("fresh_workspace_test");
1322 drop(fs::remove_dir_all(&temp_dir)); fs::create_dir_all(&temp_dir).unwrap();
1324
1325 let workspace_path = temp_dir.join("test_workspace.json");
1326
1327 let mut workspace = Workspace::new(temp_dir.clone());
1329 workspace.search_options.case_sensitive = true;
1330 workspace.bookmarks.insert(
1331 'x',
1332 SerializedBookmark {
1333 file_path: PathBuf::from("test.txt"),
1334 position: 42,
1335 },
1336 );
1337
1338 let content = serde_json::to_string_pretty(&workspace).unwrap();
1340 let temp_path = workspace_path.with_extension("json.tmp");
1341 let mut file = std::fs::File::create(&temp_path).unwrap();
1342 std::io::Write::write_all(&mut file, content.as_bytes()).unwrap();
1343 file.sync_all().unwrap();
1344 std::fs::rename(&temp_path, &workspace_path).unwrap();
1345
1346 let loaded_content = fs::read_to_string(&workspace_path).unwrap();
1348 let loaded: Workspace = serde_json::from_str(&loaded_content).unwrap();
1349
1350 assert_eq!(loaded.working_dir, temp_dir);
1352 assert!(loaded.search_options.case_sensitive);
1353 assert_eq!(loaded.bookmarks.get(&'x').unwrap().position, 42);
1354
1355 drop(fs::remove_dir_all(&temp_dir));
1357 }
1358
1359 #[test]
1360 fn test_workspace_version_check() {
1361 let workspace = Workspace::new(PathBuf::from("/test"));
1362 assert_eq!(workspace.version, WORKSPACE_VERSION);
1363
1364 let mut json_value: serde_json::Value = serde_json::to_value(&workspace).unwrap();
1366 json_value["version"] = serde_json::json!(999);
1367
1368 let json = serde_json::to_string(&json_value).unwrap();
1369 let restored: Workspace = serde_json::from_str(&json).unwrap();
1370
1371 assert_eq!(restored.version, 999);
1373 }
1374
1375 #[test]
1376 fn test_empty_workspace_histories() {
1377 let histories = WorkspaceHistories::default();
1378 let json = serde_json::to_string(&histories).unwrap();
1379
1380 assert_eq!(json, "{}");
1382
1383 let restored: WorkspaceHistories = serde_json::from_str(&json).unwrap();
1385 assert!(restored.search.is_empty());
1386 assert!(restored.replace.is_empty());
1387 }
1388
1389 #[test]
1390 fn test_file_explorer_state_percent_round_trip() {
1391 let state = FileExplorerState {
1392 visible: true,
1393 width: crate::config::ExplorerWidth::Percent(25),
1394 side: crate::config::FileExplorerSide::Left,
1395 expanded_dirs: vec![
1396 PathBuf::from("src"),
1397 PathBuf::from("src/app"),
1398 PathBuf::from("tests"),
1399 ],
1400 scroll_offset: 5,
1401 show_hidden: true,
1402 show_gitignored: false,
1403 };
1404
1405 let json = serde_json::to_string(&state).unwrap();
1406 let restored: FileExplorerState = serde_json::from_str(&json).unwrap();
1407
1408 assert!(restored.visible);
1409 assert_eq!(restored.width, crate::config::ExplorerWidth::Percent(25));
1410 assert_eq!(restored.expanded_dirs.len(), 3);
1411 assert_eq!(restored.scroll_offset, 5);
1412 assert!(restored.show_hidden);
1413 assert!(!restored.show_gitignored);
1414 }
1415
1416 #[test]
1417 fn test_file_explorer_state_columns_round_trip() {
1418 let state = FileExplorerState {
1419 visible: true,
1420 width: crate::config::ExplorerWidth::Columns(42),
1421 side: crate::config::FileExplorerSide::Left,
1422 expanded_dirs: vec![],
1423 scroll_offset: 0,
1424 show_hidden: false,
1425 show_gitignored: false,
1426 };
1427 let json = serde_json::to_string(&state).unwrap();
1428 let restored: FileExplorerState = serde_json::from_str(&json).unwrap();
1429 assert_eq!(restored.width, crate::config::ExplorerWidth::Columns(42));
1430 }
1431
1432 #[test]
1437 fn test_file_explorer_state_legacy_width_percent_alias() {
1438 let json = r#"{
1439 "visible": true,
1440 "width_percent": 0.3,
1441 "expanded_dirs": [],
1442 "scroll_offset": 0,
1443 "show_hidden": false,
1444 "show_gitignored": false
1445 }"#;
1446 let restored: FileExplorerState = serde_json::from_str(json).unwrap();
1447 assert_eq!(restored.width, crate::config::ExplorerWidth::Percent(30));
1448 }
1449}