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 = "Option::is_none")]
250 pub line_numbers: Option<bool>,
251
252 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub line_wrap: Option<bool>,
256
257 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
259 pub plugin_state: HashMap<String, serde_json::Value>,
260
261 #[serde(default, skip_serializing_if = "Vec::is_empty")]
263 pub folds: Vec<SerializedFoldRange>,
264}
265
266#[derive(Debug, Clone, Serialize, Deserialize)]
268pub struct SerializedFoldRange {
269 pub header_line: usize,
271 pub end_line: usize,
273 #[serde(default)]
275 pub placeholder: Option<String>,
276 #[serde(default)]
285 pub header_text: Option<String>,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
289pub struct SerializedCursor {
290 pub position: usize,
292 #[serde(default)]
294 pub anchor: Option<usize>,
295 #[serde(default)]
297 pub sticky_column: usize,
298}
299
300#[derive(Debug, Clone, Serialize, Deserialize)]
301pub struct SerializedScroll {
302 pub top_byte: usize,
304 #[serde(default)]
306 pub top_view_line_offset: usize,
307 #[serde(default)]
309 pub left_column: usize,
310}
311
312#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
313pub enum SerializedViewMode {
314 #[default]
315 Source,
316 #[serde(alias = "Compose")]
319 PageView,
320}
321
322#[derive(Debug, Clone, Default, Serialize, Deserialize)]
324pub struct WorkspaceConfigOverrides {
325 #[serde(default, skip_serializing_if = "Option::is_none")]
326 pub line_numbers: Option<bool>,
327 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub relative_line_numbers: Option<bool>,
329 #[serde(default, skip_serializing_if = "Option::is_none")]
330 pub line_wrap: Option<bool>,
331 #[serde(default, skip_serializing_if = "Option::is_none")]
332 pub syntax_highlighting: Option<bool>,
333 #[serde(default, skip_serializing_if = "Option::is_none")]
334 pub enable_inlay_hints: Option<bool>,
335 #[serde(default, skip_serializing_if = "Option::is_none")]
336 pub mouse_enabled: Option<bool>,
337 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub menu_bar_hidden: Option<bool>,
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct FileExplorerState {
348 pub visible: bool,
349 #[serde(
354 alias = "width_percent",
355 default = "crate::config::default_explorer_width_value"
356 )]
357 pub width: crate::config::ExplorerWidth,
358 #[serde(default)]
360 pub side: crate::config::FileExplorerSide,
361 #[serde(default)]
363 pub expanded_dirs: Vec<PathBuf>,
364 #[serde(default)]
366 pub scroll_offset: usize,
367 #[serde(default)]
369 pub show_hidden: bool,
370 #[serde(default)]
372 pub show_gitignored: bool,
373}
374
375impl Default for FileExplorerState {
376 fn default() -> Self {
377 Self {
378 visible: false,
379 width: crate::config::default_explorer_width_value(),
380 side: crate::config::FileExplorerSide::Left,
381 expanded_dirs: Vec::new(),
382 scroll_offset: 0,
383 show_hidden: false,
384 show_gitignored: false,
385 }
386 }
387}
388
389#[derive(Debug, Clone, Default, Serialize, Deserialize)]
391pub struct WorkspaceHistories {
392 #[serde(default, skip_serializing_if = "Vec::is_empty")]
393 pub search: Vec<String>,
394 #[serde(default, skip_serializing_if = "Vec::is_empty")]
395 pub replace: Vec<String>,
396 #[serde(default, skip_serializing_if = "Vec::is_empty")]
397 pub command_palette: Vec<String>,
398 #[serde(default, skip_serializing_if = "Vec::is_empty")]
399 pub goto_line: Vec<String>,
400 #[serde(default, skip_serializing_if = "Vec::is_empty")]
401 pub open_file: Vec<String>,
402}
403
404#[derive(Debug, Clone, Default, Serialize, Deserialize)]
406pub struct SearchOptions {
407 #[serde(default)]
408 pub case_sensitive: bool,
409 #[serde(default)]
410 pub whole_word: bool,
411 #[serde(default)]
412 pub use_regex: bool,
413 #[serde(default)]
414 pub confirm_each: bool,
415}
416
417#[derive(Debug, Clone, Serialize, Deserialize)]
419pub struct SerializedBookmark {
420 pub file_path: PathBuf,
422 pub position: usize,
424}
425
426#[derive(Debug, Clone, Serialize, Deserialize)]
428pub enum SerializedTabRef {
429 File(PathBuf),
430 Terminal(usize),
431 Unnamed(String),
433}
434
435#[derive(Debug, Clone, Serialize, Deserialize)]
437pub struct SerializedTerminalWorkspace {
438 pub terminal_index: usize,
439 pub cwd: Option<PathBuf>,
440 pub shell: String,
441 pub cols: u16,
442 pub rows: u16,
443 pub log_path: PathBuf,
444 pub backing_path: PathBuf,
445 #[serde(default, skip_serializing_if = "Option::is_none")]
451 pub command: Option<Vec<String>>,
452 #[serde(default, skip_serializing_if = "Option::is_none")]
460 pub agent_resume: Option<AgentResume>,
461}
462
463#[derive(Debug, Clone, Serialize, Deserialize)]
468pub struct AgentResume {
469 pub argv: Vec<String>,
473}
474
475#[derive(Debug, Clone, Serialize, Deserialize)]
486pub struct PersistedFileState {
487 pub version: u32,
489
490 pub state: SerializedFileState,
492
493 pub saved_at: u64,
495}
496
497impl PersistedFileState {
498 fn new(state: SerializedFileState) -> Self {
499 Self {
500 version: FILE_WORKSPACE_VERSION,
501 state,
502 saved_at: SystemTime::now()
503 .duration_since(UNIX_EPOCH)
504 .unwrap_or_default()
505 .as_secs(),
506 }
507 }
508}
509
510pub struct PersistedFileWorkspace;
522
523impl PersistedFileWorkspace {
524 fn states_dir() -> io::Result<PathBuf> {
526 Ok(get_data_dir()?.join("file_states"))
527 }
528
529 fn state_file_path(source_path: &Path) -> io::Result<PathBuf> {
531 let canonical = source_path
532 .canonicalize()
533 .unwrap_or_else(|_| source_path.to_path_buf());
534 let filename = format!("{}.json", encode_path_for_filename(&canonical));
535 Ok(Self::states_dir()?.join(filename))
536 }
537
538 pub fn load(path: &Path) -> Option<SerializedFileState> {
540 let state_path = match Self::state_file_path(path) {
541 Ok(p) => p,
542 Err(_) => return None,
543 };
544
545 if !state_path.exists() {
546 return None;
547 }
548
549 let content = match std::fs::read_to_string(&state_path) {
550 Ok(c) => c,
551 Err(_) => return None,
552 };
553
554 let persisted: PersistedFileState = match serde_json::from_str(&content) {
555 Ok(p) => p,
556 Err(_) => return None,
557 };
558
559 if persisted.version > FILE_WORKSPACE_VERSION {
561 return None;
562 }
563
564 Some(persisted.state)
565 }
566
567 pub fn save(path: &Path, state: SerializedFileState) {
569 let state_path = match Self::state_file_path(path) {
570 Ok(p) => p,
571 Err(e) => {
572 tracing::warn!("Failed to get state path for {:?}: {}", path, e);
573 return;
574 }
575 };
576
577 if let Some(parent) = state_path.parent() {
579 if let Err(e) = std::fs::create_dir_all(parent) {
580 tracing::warn!("Failed to create state dir: {}", e);
581 return;
582 }
583 }
584
585 let persisted = PersistedFileState::new(state);
586 let content = match serde_json::to_string_pretty(&persisted) {
587 Ok(c) => c,
588 Err(e) => {
589 tracing::warn!("Failed to serialize file state: {}", e);
590 return;
591 }
592 };
593
594 let temp_path = state_path.with_extension("json.tmp");
596
597 let write_result = (|| -> io::Result<()> {
598 let mut file = std::fs::File::create(&temp_path)?;
599 file.write_all(content.as_bytes())?;
600 file.sync_all()?;
601 std::fs::rename(&temp_path, &state_path)?;
602 Ok(())
603 })();
604
605 if let Err(e) = write_result {
606 tracing::warn!("Failed to save file state for {:?}: {}", path, e);
607 } else {
608 tracing::trace!("File state saved for {:?}", path);
609 }
610 }
611}
612
613pub fn get_workspaces_dir() -> io::Result<PathBuf> {
619 Ok(get_data_dir()?.join("workspaces"))
620}
621
622pub fn encode_path_for_filename(path: &Path) -> String {
630 let path_str = path.to_string_lossy();
631 let mut result = String::with_capacity(path_str.len() * 2);
632
633 for c in path_str.chars() {
634 match c {
635 '/' | '\\' => result.push('_'),
637 c if c.is_ascii_alphanumeric() => result.push(c),
639 '-' | '.' => result.push(c),
640 '_' => result.push_str("%5F"),
642 c => {
644 for byte in c.to_string().as_bytes() {
645 result.push_str(&format!("%{:02X}", byte));
646 }
647 }
648 }
649 }
650
651 let result = result.trim_start_matches('_').to_string();
653
654 let mut final_result = String::with_capacity(result.len());
656 let mut last_was_underscore = false;
657 for c in result.chars() {
658 if c == '_' {
659 if !last_was_underscore {
660 final_result.push(c);
661 }
662 last_was_underscore = true;
663 } else {
664 final_result.push(c);
665 last_was_underscore = false;
666 }
667 }
668
669 if final_result.is_empty() {
670 final_result = "root".to_string();
671 }
672
673 final_result
674}
675
676#[allow(dead_code)]
678pub fn decode_filename_to_path(encoded: &str) -> Option<PathBuf> {
679 if encoded == "root" {
680 return Some(PathBuf::from("/"));
681 }
682
683 let mut result = String::with_capacity(encoded.len() + 1);
684 result.push('/');
686
687 let mut chars = encoded.chars().peekable();
688
689 while let Some(c) = chars.next() {
690 if c == '%' {
691 let hex: String = chars.by_ref().take(2).collect();
693 if hex.len() == 2 {
694 if let Ok(byte) = u8::from_str_radix(&hex, 16) {
695 result.push(byte as char);
696 }
697 }
698 } else if c == '_' {
699 result.push('/');
700 } else {
701 result.push(c);
702 }
703 }
704
705 Some(PathBuf::from(result))
706}
707
708pub fn get_workspace_path(working_dir: &Path) -> io::Result<PathBuf> {
710 let canonical = working_dir
711 .canonicalize()
712 .unwrap_or_else(|_| working_dir.to_path_buf());
713 let filename = format!("{}.json", encode_path_for_filename(&canonical));
714 Ok(get_workspaces_dir()?.join(filename))
715}
716
717pub fn get_session_workspaces_dir() -> io::Result<PathBuf> {
719 Ok(get_data_dir()?.join("session-workspaces"))
720}
721
722pub fn get_session_workspace_path(session_name: &str) -> io::Result<PathBuf> {
724 let dir = get_session_workspaces_dir()?;
725 std::fs::create_dir_all(&dir)?;
726 let safe_name: String = session_name
728 .chars()
729 .map(|c| {
730 if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
731 c
732 } else {
733 '_'
734 }
735 })
736 .collect();
737 Ok(dir.join(format!("{}.json", safe_name)))
738}
739
740#[derive(Debug)]
742pub enum WorkspaceError {
743 Io(anyhow::Error),
744 Json(serde_json::Error),
745 WorkdirMismatch { expected: PathBuf, found: PathBuf },
746 VersionTooNew { version: u32, max_supported: u32 },
747}
748
749impl std::fmt::Display for WorkspaceError {
750 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
751 match self {
752 Self::Io(e) => write!(f, "Workspace error: {}", e),
753 Self::Json(e) => write!(f, "JSON error: {}", e),
754 Self::WorkdirMismatch { expected, found } => {
755 write!(
756 f,
757 "Working directory mismatch: expected {:?}, found {:?}",
758 expected, found
759 )
760 }
761 WorkspaceError::VersionTooNew {
762 version,
763 max_supported,
764 } => {
765 write!(
766 f,
767 "Workspace version {} is newer than supported (max: {})",
768 version, max_supported
769 )
770 }
771 }
772 }
773}
774
775impl std::error::Error for WorkspaceError {
776 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
777 match self {
778 Self::Io(e) => e.source(),
779 Self::Json(e) => Some(e),
780 _ => None,
781 }
782 }
783}
784
785impl From<io::Error> for WorkspaceError {
786 fn from(e: io::Error) -> Self {
787 WorkspaceError::Io(e.into())
788 }
789}
790
791impl From<anyhow::Error> for WorkspaceError {
792 fn from(e: anyhow::Error) -> Self {
793 WorkspaceError::Io(e)
794 }
795}
796
797impl From<serde_json::Error> for WorkspaceError {
798 fn from(e: serde_json::Error) -> Self {
799 WorkspaceError::Json(e)
800 }
801}
802
803impl Workspace {
804 pub fn load(working_dir: &Path) -> Result<Option<Workspace>, WorkspaceError> {
806 let path = get_workspace_path(working_dir)?;
807 tracing::debug!("Looking for workspace at {:?}", path);
808
809 if !path.exists() {
810 tracing::debug!("Workspace file does not exist");
811 return Ok(None);
812 }
813
814 tracing::debug!("Loading workspace from {:?}", path);
815 let content = std::fs::read_to_string(&path)?;
816 let workspace: Workspace = serde_json::from_str(&content)?;
817
818 tracing::debug!(
819 "Loaded workspace: version={}, split_states={}, active_split={}",
820 workspace.version,
821 workspace.split_states.len(),
822 workspace.active_split_id
823 );
824
825 let expected = working_dir
827 .canonicalize()
828 .unwrap_or_else(|_| working_dir.to_path_buf());
829 let found = workspace
830 .working_dir
831 .canonicalize()
832 .unwrap_or_else(|_| workspace.working_dir.clone());
833
834 if expected != found {
835 tracing::warn!(
836 "Workspace working_dir mismatch: expected {:?}, found {:?}",
837 expected,
838 found
839 );
840 return Err(WorkspaceError::WorkdirMismatch { expected, found });
841 }
842
843 if workspace.version > WORKSPACE_VERSION {
845 tracing::warn!(
846 "Workspace version {} is newer than supported {}",
847 workspace.version,
848 WORKSPACE_VERSION
849 );
850 return Err(WorkspaceError::VersionTooNew {
851 version: workspace.version,
852 max_supported: WORKSPACE_VERSION,
853 });
854 }
855
856 Ok(Some(workspace))
857 }
858
859 pub fn has_no_real_content(&self) -> bool {
868 self.terminals.is_empty()
869 && self.external_files.is_empty()
870 && self.unnamed_buffers.is_empty()
871 && self.split_states.values().all(|s| s.open_tabs.is_empty())
872 }
873
874 pub fn has_no_preservable_content(&self) -> bool {
881 self.external_files.is_empty()
882 && self.unnamed_buffers.is_empty()
883 && self.split_states.values().all(|s| {
884 s.open_tabs
885 .iter()
886 .all(|t| matches!(t, SerializedTabRef::Terminal(_)))
887 })
888 }
889
890 pub fn save(&self) -> Result<(), WorkspaceError> {
897 let path = get_workspace_path(&self.working_dir)?;
898 tracing::debug!("Saving workspace to {:?}", path);
899
900 if let Some(parent) = path.parent() {
902 std::fs::create_dir_all(parent)?;
903 }
904
905 let content = serde_json::to_string_pretty(self)?;
907 tracing::trace!("Workspace JSON size: {} bytes", content.len());
908
909 let temp_path = path.with_extension("json.tmp");
911
912 {
914 let mut file = std::fs::File::create(&temp_path)?;
915 file.write_all(content.as_bytes())?;
916 file.sync_all()?; }
918
919 std::fs::rename(&temp_path, &path)?;
921 tracing::info!("Workspace saved to {:?}", path);
922
923 Ok(())
924 }
925
926 pub fn load_session(
928 session_name: &str,
929 working_dir: &Path,
930 ) -> Result<Option<Workspace>, WorkspaceError> {
931 let path = get_session_workspace_path(session_name)?;
932 tracing::debug!("Looking for session workspace at {:?}", path);
933
934 if !path.exists() {
935 return Ok(None);
936 }
937
938 let content = std::fs::read_to_string(&path)?;
939 let workspace: Workspace = serde_json::from_str(&content)?;
940
941 if workspace.version > WORKSPACE_VERSION {
944 return Err(WorkspaceError::VersionTooNew {
945 version: workspace.version,
946 max_supported: WORKSPACE_VERSION,
947 });
948 }
949
950 let found = workspace
952 .working_dir
953 .canonicalize()
954 .unwrap_or_else(|_| workspace.working_dir.clone());
955 let expected = working_dir
956 .canonicalize()
957 .unwrap_or_else(|_| working_dir.to_path_buf());
958 if expected != found {
959 tracing::info!(
960 "Session '{}' workspace was saved from {:?}, now loading from {:?}",
961 session_name,
962 found,
963 expected
964 );
965 }
966
967 Ok(Some(workspace))
968 }
969
970 pub fn save_session(&self, session_name: &str) -> Result<(), WorkspaceError> {
972 let path = get_session_workspace_path(session_name)?;
973 tracing::debug!("Saving session workspace to {:?}", path);
974
975 if let Some(parent) = path.parent() {
976 std::fs::create_dir_all(parent)?;
977 }
978
979 let content = serde_json::to_string_pretty(self)?;
980 let temp_path = path.with_extension("json.tmp");
981 {
982 let mut file = std::fs::File::create(&temp_path)?;
983 file.write_all(content.as_bytes())?;
984 file.sync_all()?;
985 }
986 std::fs::rename(&temp_path, &path)?;
987 tracing::info!("Session workspace saved to {:?}", path);
988 Ok(())
989 }
990
991 pub fn delete(working_dir: &Path) -> Result<(), WorkspaceError> {
993 let path = get_workspace_path(working_dir)?;
994 if path.exists() {
995 std::fs::remove_file(path)?;
996 }
997 Ok(())
998 }
999
1000 pub fn new(working_dir: PathBuf) -> Self {
1002 Self {
1003 version: WORKSPACE_VERSION,
1004 working_dir,
1005 split_layout: SerializedSplitNode::Leaf {
1006 file_path: None,
1007 split_id: 0,
1008 label: None,
1009 unnamed_recovery_id: None,
1010 role: None,
1011 },
1012 active_split_id: 0,
1013 split_states: HashMap::new(),
1014 config_overrides: WorkspaceConfigOverrides::default(),
1015 file_explorer: FileExplorerState::default(),
1016 histories: WorkspaceHistories::default(),
1017 search_options: SearchOptions::default(),
1018 bookmarks: HashMap::new(),
1019 terminals: Vec::new(),
1020 external_files: Vec::new(),
1021 read_only_files: Vec::new(),
1022 unnamed_buffers: Vec::new(),
1023 plugin_global_state: HashMap::new(),
1024 saved_at: SystemTime::now()
1025 .duration_since(UNIX_EPOCH)
1026 .unwrap_or_default()
1027 .as_secs(),
1028 label: None,
1029 session_plugin_state: HashMap::new(),
1030 authority_spec: crate::services::authority::SessionAuthoritySpec::Local,
1031 }
1032 }
1033
1034 pub fn touch(&mut self) {
1036 self.saved_at = SystemTime::now()
1037 .duration_since(UNIX_EPOCH)
1038 .unwrap_or_default()
1039 .as_secs();
1040 }
1041}
1042
1043#[cfg(test)]
1044mod tests {
1045 use super::*;
1046
1047 #[test]
1048 fn test_workspace_path_percent_encoding() {
1049 let encoded = encode_path_for_filename(Path::new("/home/user/project"));
1051 assert_eq!(encoded, "home_user_project");
1052 assert!(!encoded.contains('/')); let decoded = decode_filename_to_path(&encoded).unwrap();
1056 assert_eq!(decoded, PathBuf::from("/home/user/project"));
1057
1058 let path1 = get_workspace_path(Path::new("/home/user/project")).unwrap();
1060 let path2 = get_workspace_path(Path::new("/home/user/other")).unwrap();
1061 assert_ne!(path1, path2);
1062
1063 let path1_again = get_workspace_path(Path::new("/home/user/project")).unwrap();
1065 assert_eq!(path1, path1_again);
1066
1067 let filename = path1.file_name().unwrap().to_str().unwrap();
1069 assert!(filename.ends_with(".json"));
1070 assert!(filename.starts_with("home_user_project"));
1071 }
1072
1073 #[test]
1074 fn test_percent_encoding_edge_cases() {
1075 let encoded = encode_path_for_filename(Path::new("/home/user/my-project"));
1077 assert_eq!(encoded, "home_user_my-project");
1078
1079 let encoded = encode_path_for_filename(Path::new("/home/user/my project"));
1081 assert_eq!(encoded, "home_user_my%20project");
1082 let decoded = decode_filename_to_path(&encoded).unwrap();
1083 assert_eq!(decoded, PathBuf::from("/home/user/my project"));
1084
1085 let encoded = encode_path_for_filename(Path::new("/home/user/my_project"));
1087 assert_eq!(encoded, "home_user_my%5Fproject");
1088 let decoded = decode_filename_to_path(&encoded).unwrap();
1089 assert_eq!(decoded, PathBuf::from("/home/user/my_project"));
1090
1091 let encoded = encode_path_for_filename(Path::new("/"));
1093 assert_eq!(encoded, "root");
1094 }
1095
1096 #[test]
1097 fn test_workspace_serialization() {
1098 let workspace = Workspace::new(PathBuf::from("/home/user/test"));
1099 let json = serde_json::to_string(&workspace).unwrap();
1100 let restored: Workspace = serde_json::from_str(&json).unwrap();
1101
1102 assert_eq!(workspace.version, restored.version);
1103 assert_eq!(workspace.working_dir, restored.working_dir);
1104 }
1105
1106 #[test]
1107 fn test_workspace_config_overrides_skip_none() {
1108 let overrides = WorkspaceConfigOverrides::default();
1109 let json = serde_json::to_string(&overrides).unwrap();
1110
1111 assert_eq!(json, "{}");
1113 }
1114
1115 #[test]
1116 fn test_workspace_config_overrides_with_values() {
1117 let overrides = WorkspaceConfigOverrides {
1118 line_wrap: Some(false),
1119 ..Default::default()
1120 };
1121 let json = serde_json::to_string(&overrides).unwrap();
1122
1123 assert!(json.contains("line_wrap"));
1124 assert!(!json.contains("line_numbers")); }
1126
1127 #[test]
1128 fn test_split_layout_serialization() {
1129 let layout = SerializedSplitNode::Split {
1131 direction: SerializedSplitDirection::Vertical,
1132 first: Box::new(SerializedSplitNode::Leaf {
1133 file_path: Some(PathBuf::from("src/main.rs")),
1134 split_id: 1,
1135 label: None,
1136 unnamed_recovery_id: None,
1137 role: None,
1138 }),
1139 second: Box::new(SerializedSplitNode::Leaf {
1140 file_path: Some(PathBuf::from("src/lib.rs")),
1141 split_id: 2,
1142 label: None,
1143 unnamed_recovery_id: None,
1144 role: None,
1145 }),
1146 ratio: 0.5,
1147 split_id: 0,
1148 };
1149
1150 let json = serde_json::to_string(&layout).unwrap();
1151 let restored: SerializedSplitNode = serde_json::from_str(&json).unwrap();
1152
1153 match restored {
1155 SerializedSplitNode::Split {
1156 direction,
1157 ratio,
1158 split_id,
1159 ..
1160 } => {
1161 assert!(matches!(direction, SerializedSplitDirection::Vertical));
1162 assert_eq!(ratio, 0.5);
1163 assert_eq!(split_id, 0);
1164 }
1165 _ => panic!("Expected Split node"),
1166 }
1167 }
1168
1169 #[test]
1170 fn test_file_state_serialization() {
1171 let file_state = SerializedFileState {
1172 cursor: SerializedCursor {
1173 position: 1234,
1174 anchor: Some(1000),
1175 sticky_column: 15,
1176 },
1177 additional_cursors: vec![SerializedCursor {
1178 position: 5000,
1179 anchor: None,
1180 sticky_column: 0,
1181 }],
1182 scroll: SerializedScroll {
1183 top_byte: 500,
1184 top_view_line_offset: 2,
1185 left_column: 10,
1186 },
1187 view_mode: SerializedViewMode::Source,
1188 compose_width: None,
1189 line_numbers: None,
1190 line_wrap: None,
1191 plugin_state: HashMap::new(),
1192 folds: Vec::new(),
1193 };
1194
1195 let json = serde_json::to_string(&file_state).unwrap();
1196 let restored: SerializedFileState = serde_json::from_str(&json).unwrap();
1197
1198 assert_eq!(restored.cursor.position, 1234);
1199 assert_eq!(restored.cursor.anchor, Some(1000));
1200 assert_eq!(restored.cursor.sticky_column, 15);
1201 assert_eq!(restored.additional_cursors.len(), 1);
1202 assert_eq!(restored.scroll.top_byte, 500);
1203 assert_eq!(restored.scroll.left_column, 10);
1204 }
1205
1206 #[test]
1207 fn test_bookmark_serialization() {
1208 let mut bookmarks = HashMap::new();
1209 bookmarks.insert(
1210 'a',
1211 SerializedBookmark {
1212 file_path: PathBuf::from("src/main.rs"),
1213 position: 1234,
1214 },
1215 );
1216 bookmarks.insert(
1217 'b',
1218 SerializedBookmark {
1219 file_path: PathBuf::from("src/lib.rs"),
1220 position: 5678,
1221 },
1222 );
1223
1224 let json = serde_json::to_string(&bookmarks).unwrap();
1225 let restored: HashMap<char, SerializedBookmark> = serde_json::from_str(&json).unwrap();
1226
1227 assert_eq!(restored.len(), 2);
1228 assert_eq!(restored.get(&'a').unwrap().position, 1234);
1229 assert_eq!(
1230 restored.get(&'b').unwrap().file_path,
1231 PathBuf::from("src/lib.rs")
1232 );
1233 }
1234
1235 #[test]
1236 fn test_search_options_serialization() {
1237 let options = SearchOptions {
1238 case_sensitive: true,
1239 whole_word: true,
1240 use_regex: false,
1241 confirm_each: true,
1242 };
1243
1244 let json = serde_json::to_string(&options).unwrap();
1245 let restored: SearchOptions = serde_json::from_str(&json).unwrap();
1246
1247 assert!(restored.case_sensitive);
1248 assert!(restored.whole_word);
1249 assert!(!restored.use_regex);
1250 assert!(restored.confirm_each);
1251 }
1252
1253 #[test]
1254 fn test_full_workspace_round_trip() {
1255 let mut workspace = Workspace::new(PathBuf::from("/home/user/myproject"));
1256
1257 workspace.split_layout = SerializedSplitNode::Split {
1259 direction: SerializedSplitDirection::Horizontal,
1260 first: Box::new(SerializedSplitNode::Leaf {
1261 file_path: Some(PathBuf::from("README.md")),
1262 split_id: 1,
1263 label: None,
1264 unnamed_recovery_id: None,
1265 role: None,
1266 }),
1267 second: Box::new(SerializedSplitNode::Leaf {
1268 file_path: Some(PathBuf::from("Cargo.toml")),
1269 split_id: 2,
1270 label: None,
1271 unnamed_recovery_id: None,
1272 role: None,
1273 }),
1274 ratio: 0.6,
1275 split_id: 0,
1276 };
1277 workspace.active_split_id = 1;
1278
1279 workspace.split_states.insert(
1281 1,
1282 SerializedSplitViewState {
1283 open_tabs: vec![
1284 SerializedTabRef::File(PathBuf::from("README.md")),
1285 SerializedTabRef::File(PathBuf::from("src/lib.rs")),
1286 ],
1287 active_tab_index: Some(0),
1288 open_files: vec![PathBuf::from("README.md"), PathBuf::from("src/lib.rs")],
1289 active_file_index: 0,
1290 file_states: HashMap::new(),
1291 tab_scroll_offset: 0,
1292 view_mode: SerializedViewMode::Source,
1293 compose_width: None,
1294 },
1295 );
1296
1297 workspace.bookmarks.insert(
1299 'm',
1300 SerializedBookmark {
1301 file_path: PathBuf::from("src/main.rs"),
1302 position: 100,
1303 },
1304 );
1305
1306 workspace.search_options.case_sensitive = true;
1308 workspace.search_options.use_regex = true;
1309
1310 let json = serde_json::to_string_pretty(&workspace).unwrap();
1312 let restored: Workspace = serde_json::from_str(&json).unwrap();
1313
1314 assert_eq!(restored.version, WORKSPACE_VERSION);
1316 assert_eq!(restored.working_dir, PathBuf::from("/home/user/myproject"));
1317 assert_eq!(restored.active_split_id, 1);
1318 assert!(restored.bookmarks.contains_key(&'m'));
1319 assert!(restored.search_options.case_sensitive);
1320 assert!(restored.search_options.use_regex);
1321
1322 let split_state = restored.split_states.get(&1).unwrap();
1324 assert_eq!(split_state.open_files.len(), 2);
1325 assert_eq!(split_state.open_files[0], PathBuf::from("README.md"));
1326 }
1327
1328 #[test]
1329 fn test_workspace_file_save_load() {
1330 use std::fs;
1331
1332 let temp_dir = std::env::temp_dir().join("fresh_workspace_test");
1334 drop(fs::remove_dir_all(&temp_dir)); fs::create_dir_all(&temp_dir).unwrap();
1336
1337 let workspace_path = temp_dir.join("test_workspace.json");
1338
1339 let mut workspace = Workspace::new(temp_dir.clone());
1341 workspace.search_options.case_sensitive = true;
1342 workspace.bookmarks.insert(
1343 'x',
1344 SerializedBookmark {
1345 file_path: PathBuf::from("test.txt"),
1346 position: 42,
1347 },
1348 );
1349
1350 let content = serde_json::to_string_pretty(&workspace).unwrap();
1352 let temp_path = workspace_path.with_extension("json.tmp");
1353 let mut file = std::fs::File::create(&temp_path).unwrap();
1354 std::io::Write::write_all(&mut file, content.as_bytes()).unwrap();
1355 file.sync_all().unwrap();
1356 std::fs::rename(&temp_path, &workspace_path).unwrap();
1357
1358 let loaded_content = fs::read_to_string(&workspace_path).unwrap();
1360 let loaded: Workspace = serde_json::from_str(&loaded_content).unwrap();
1361
1362 assert_eq!(loaded.working_dir, temp_dir);
1364 assert!(loaded.search_options.case_sensitive);
1365 assert_eq!(loaded.bookmarks.get(&'x').unwrap().position, 42);
1366
1367 drop(fs::remove_dir_all(&temp_dir));
1369 }
1370
1371 #[test]
1372 fn test_workspace_version_check() {
1373 let workspace = Workspace::new(PathBuf::from("/test"));
1374 assert_eq!(workspace.version, WORKSPACE_VERSION);
1375
1376 let mut json_value: serde_json::Value = serde_json::to_value(&workspace).unwrap();
1378 json_value["version"] = serde_json::json!(999);
1379
1380 let json = serde_json::to_string(&json_value).unwrap();
1381 let restored: Workspace = serde_json::from_str(&json).unwrap();
1382
1383 assert_eq!(restored.version, 999);
1385 }
1386
1387 #[test]
1388 fn test_empty_workspace_histories() {
1389 let histories = WorkspaceHistories::default();
1390 let json = serde_json::to_string(&histories).unwrap();
1391
1392 assert_eq!(json, "{}");
1394
1395 let restored: WorkspaceHistories = serde_json::from_str(&json).unwrap();
1397 assert!(restored.search.is_empty());
1398 assert!(restored.replace.is_empty());
1399 }
1400
1401 #[test]
1402 fn test_file_explorer_state_percent_round_trip() {
1403 let state = FileExplorerState {
1404 visible: true,
1405 width: crate::config::ExplorerWidth::Percent(25),
1406 side: crate::config::FileExplorerSide::Left,
1407 expanded_dirs: vec![
1408 PathBuf::from("src"),
1409 PathBuf::from("src/app"),
1410 PathBuf::from("tests"),
1411 ],
1412 scroll_offset: 5,
1413 show_hidden: true,
1414 show_gitignored: false,
1415 };
1416
1417 let json = serde_json::to_string(&state).unwrap();
1418 let restored: FileExplorerState = serde_json::from_str(&json).unwrap();
1419
1420 assert!(restored.visible);
1421 assert_eq!(restored.width, crate::config::ExplorerWidth::Percent(25));
1422 assert_eq!(restored.expanded_dirs.len(), 3);
1423 assert_eq!(restored.scroll_offset, 5);
1424 assert!(restored.show_hidden);
1425 assert!(!restored.show_gitignored);
1426 }
1427
1428 #[test]
1429 fn test_file_explorer_state_columns_round_trip() {
1430 let state = FileExplorerState {
1431 visible: true,
1432 width: crate::config::ExplorerWidth::Columns(42),
1433 side: crate::config::FileExplorerSide::Left,
1434 expanded_dirs: vec![],
1435 scroll_offset: 0,
1436 show_hidden: false,
1437 show_gitignored: false,
1438 };
1439 let json = serde_json::to_string(&state).unwrap();
1440 let restored: FileExplorerState = serde_json::from_str(&json).unwrap();
1441 assert_eq!(restored.width, crate::config::ExplorerWidth::Columns(42));
1442 }
1443
1444 #[test]
1449 fn test_file_explorer_state_legacy_width_percent_alias() {
1450 let json = r#"{
1451 "visible": true,
1452 "width_percent": 0.3,
1453 "expanded_dirs": [],
1454 "scroll_offset": 0,
1455 "show_hidden": false,
1456 "show_gitignored": false
1457 }"#;
1458 let restored: FileExplorerState = serde_json::from_str(json).unwrap();
1459 assert_eq!(restored.width, crate::config::ExplorerWidth::Percent(30));
1460 }
1461}