Skip to main content

fresh/
workspace.rs

1//! Workspace persistence for per-project editor state
2//!
3//! Saves and restores:
4//! - Split layout and open files
5//! - Cursor and scroll positions per split per file
6//! - File explorer state
7//! - Search/replace history and options
8//! - Bookmarks
9//!
10//! ## Storage
11//!
12//! Workspaces are stored in `$XDG_DATA_HOME/fresh/workspaces/{encoded_path}.json`
13//! where `{encoded_path}` is the working directory path with:
14//! - Path separators (`/`) replaced with underscores (`_`)
15//! - Special characters percent-encoded as `%XX`
16//!
17//! Example: `/home/user/my project` becomes `home_user_my%20project.json`
18//!
19//! The encoding is fully reversible using `decode_filename_to_path()`.
20//!
21//! ## Crash Resistance
22//!
23//! Uses atomic writes: write to temp file, then rename.
24//! This ensures the workspace file is never left in a corrupted state.
25
26use 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
34/// Current workspace file format version
35pub const WORKSPACE_VERSION: u32 = 1;
36
37/// Current per-file workspace version
38pub const FILE_WORKSPACE_VERSION: u32 = 1;
39
40/// Persisted workspace state for a working directory
41#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct Workspace {
43    /// Schema version for future migrations
44    pub version: u32,
45
46    /// Working directory this workspace belongs to (for validation)
47    pub working_dir: PathBuf,
48
49    /// Split layout tree
50    pub split_layout: SerializedSplitNode,
51
52    /// Active split ID
53    pub active_split_id: usize,
54
55    /// Per-split view states (keyed by split_id)
56    pub split_states: HashMap<usize, SerializedSplitViewState>,
57
58    /// Editor config overrides (toggles that differ from defaults)
59    #[serde(default)]
60    pub config_overrides: WorkspaceConfigOverrides,
61
62    /// File explorer state
63    pub file_explorer: FileExplorerState,
64
65    /// Input histories (search, replace, command palette, etc.)
66    #[serde(default)]
67    pub histories: WorkspaceHistories,
68
69    /// Search options (persist across searches within workspace)
70    #[serde(default)]
71    pub search_options: SearchOptions,
72
73    /// Bookmarks (character key -> file position)
74    #[serde(default)]
75    pub bookmarks: HashMap<char, SerializedBookmark>,
76
77    /// Open terminal workspaces (for restoration)
78    #[serde(default)]
79    pub terminals: Vec<SerializedTerminalWorkspace>,
80
81    /// External files open in the workspace (files outside working_dir)
82    /// These are stored as absolute paths since they can't be made relative
83    #[serde(default)]
84    pub external_files: Vec<PathBuf>,
85
86    /// Timestamp when workspace was saved (Unix epoch seconds)
87    pub saved_at: u64,
88}
89
90/// Serializable split layout (mirrors SplitNode but with file paths instead of buffer IDs)
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub enum SerializedSplitNode {
93    Leaf {
94        /// File path relative to working_dir (None for scratch buffers)
95        file_path: Option<PathBuf>,
96        split_id: usize,
97        /// Optional label set by plugins (e.g., "claude-sidebar")
98        #[serde(default, skip_serializing_if = "Option::is_none")]
99        label: Option<String>,
100    },
101    Terminal {
102        terminal_index: usize,
103        split_id: usize,
104        /// Optional label set by plugins
105        #[serde(default, skip_serializing_if = "Option::is_none")]
106        label: Option<String>,
107    },
108    Split {
109        direction: SerializedSplitDirection,
110        first: Box<Self>,
111        second: Box<Self>,
112        ratio: f32,
113        split_id: usize,
114    },
115}
116
117#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
118pub enum SerializedSplitDirection {
119    Horizontal,
120    Vertical,
121}
122
123/// Per-split view state
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct SerializedSplitViewState {
126    /// Open tabs in tab order (files or terminals)
127    #[serde(default)]
128    pub open_tabs: Vec<SerializedTabRef>,
129
130    /// Active tab index in open_tabs (if present)
131    #[serde(default)]
132    pub active_tab_index: Option<usize>,
133
134    /// Open files in tab order (paths relative to working_dir)
135    /// Deprecated; retained for backward compatibility.
136    #[serde(default)]
137    pub open_files: Vec<PathBuf>,
138
139    /// Active file index in open_files
140    #[serde(default)]
141    pub active_file_index: usize,
142
143    /// Per-file cursor and scroll state
144    #[serde(default)]
145    pub file_states: HashMap<PathBuf, SerializedFileState>,
146
147    /// Tab scroll offset
148    #[serde(default)]
149    pub tab_scroll_offset: usize,
150
151    /// View mode
152    #[serde(default)]
153    pub view_mode: SerializedViewMode,
154
155    /// Compose width if in compose mode
156    #[serde(default)]
157    pub compose_width: Option<u16>,
158}
159
160/// Per-file state within a split
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct SerializedFileState {
163    /// Primary cursor position (byte offset)
164    pub cursor: SerializedCursor,
165
166    /// Additional cursors for multi-cursor
167    #[serde(default)]
168    pub additional_cursors: Vec<SerializedCursor>,
169
170    /// Scroll position (byte offset)
171    pub scroll: SerializedScroll,
172
173    /// View mode for this buffer in this split
174    #[serde(default)]
175    pub view_mode: SerializedViewMode,
176
177    /// Compose width for this buffer in this split
178    #[serde(default)]
179    pub compose_width: Option<u16>,
180
181    /// Plugin-managed state (arbitrary key-value pairs, persisted across sessions)
182    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
183    pub plugin_state: HashMap<String, serde_json::Value>,
184
185    /// Collapsed folding ranges for this buffer/view
186    #[serde(default, skip_serializing_if = "Vec::is_empty")]
187    pub folds: Vec<SerializedFoldRange>,
188}
189
190/// Line-based folded range for persistence
191#[derive(Debug, Clone, Serialize, Deserialize)]
192pub struct SerializedFoldRange {
193    /// Header line number (visible line that owns the fold)
194    pub header_line: usize,
195    /// Last hidden line number (inclusive)
196    pub end_line: usize,
197    /// Optional placeholder text for the fold
198    #[serde(default)]
199    pub placeholder: Option<String>,
200}
201
202#[derive(Debug, Clone, Serialize, Deserialize)]
203pub struct SerializedCursor {
204    /// Cursor position as byte offset from start of file
205    pub position: usize,
206    /// Selection anchor as byte offset (if selection active)
207    #[serde(default)]
208    pub anchor: Option<usize>,
209    /// Sticky column for vertical movement (character column)
210    #[serde(default)]
211    pub sticky_column: usize,
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
215pub struct SerializedScroll {
216    /// Top visible position as byte offset
217    pub top_byte: usize,
218    /// Virtual line offset within the top line (for wrapped lines)
219    #[serde(default)]
220    pub top_view_line_offset: usize,
221    /// Left column offset (for horizontal scroll)
222    #[serde(default)]
223    pub left_column: usize,
224}
225
226#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
227pub enum SerializedViewMode {
228    #[default]
229    Source,
230    Compose,
231}
232
233/// Config overrides that differ from base config
234#[derive(Debug, Clone, Default, Serialize, Deserialize)]
235pub struct WorkspaceConfigOverrides {
236    #[serde(default, skip_serializing_if = "Option::is_none")]
237    pub line_numbers: Option<bool>,
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub relative_line_numbers: Option<bool>,
240    #[serde(default, skip_serializing_if = "Option::is_none")]
241    pub line_wrap: Option<bool>,
242    #[serde(default, skip_serializing_if = "Option::is_none")]
243    pub syntax_highlighting: Option<bool>,
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub enable_inlay_hints: Option<bool>,
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub mouse_enabled: Option<bool>,
248    #[serde(default, skip_serializing_if = "Option::is_none")]
249    pub menu_bar_hidden: Option<bool>,
250}
251
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct FileExplorerState {
254    pub visible: bool,
255    #[serde(default)]
256    pub width_percent: f32,
257    /// Expanded directories (relative paths)
258    #[serde(default)]
259    pub expanded_dirs: Vec<PathBuf>,
260    /// Scroll offset
261    #[serde(default)]
262    pub scroll_offset: usize,
263    /// Show hidden files (fixes #569)
264    #[serde(default)]
265    pub show_hidden: bool,
266    /// Show gitignored files (fixes #569)
267    #[serde(default)]
268    pub show_gitignored: bool,
269}
270
271impl Default for FileExplorerState {
272    fn default() -> Self {
273        Self {
274            visible: false,
275            width_percent: 0.3,
276            expanded_dirs: Vec::new(),
277            scroll_offset: 0,
278            show_hidden: false,
279            show_gitignored: false,
280        }
281    }
282}
283
284/// Per-workspace input histories
285#[derive(Debug, Clone, Default, Serialize, Deserialize)]
286pub struct WorkspaceHistories {
287    #[serde(default, skip_serializing_if = "Vec::is_empty")]
288    pub search: Vec<String>,
289    #[serde(default, skip_serializing_if = "Vec::is_empty")]
290    pub replace: Vec<String>,
291    #[serde(default, skip_serializing_if = "Vec::is_empty")]
292    pub command_palette: Vec<String>,
293    #[serde(default, skip_serializing_if = "Vec::is_empty")]
294    pub goto_line: Vec<String>,
295    #[serde(default, skip_serializing_if = "Vec::is_empty")]
296    pub open_file: Vec<String>,
297}
298
299/// Search options that persist across searches within a workspace
300#[derive(Debug, Clone, Default, Serialize, Deserialize)]
301pub struct SearchOptions {
302    #[serde(default)]
303    pub case_sensitive: bool,
304    #[serde(default)]
305    pub whole_word: bool,
306    #[serde(default)]
307    pub use_regex: bool,
308    #[serde(default)]
309    pub confirm_each: bool,
310}
311
312/// Serialized bookmark (file path + byte offset)
313#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct SerializedBookmark {
315    /// File path (relative to working_dir)
316    pub file_path: PathBuf,
317    /// Byte offset position in the file
318    pub position: usize,
319}
320
321/// Reference to an open tab (file path or terminal index)
322#[derive(Debug, Clone, Serialize, Deserialize)]
323pub enum SerializedTabRef {
324    File(PathBuf),
325    Terminal(usize),
326}
327
328/// Persisted metadata for a terminal workspace
329#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct SerializedTerminalWorkspace {
331    pub terminal_index: usize,
332    pub cwd: Option<PathBuf>,
333    pub shell: String,
334    pub cols: u16,
335    pub rows: u16,
336    pub log_path: PathBuf,
337    pub backing_path: PathBuf,
338}
339
340// ============================================================================
341// Global file state persistence (per-file, not per-project)
342// ============================================================================
343
344/// Individual file state stored in its own file
345///
346/// Each source file's scroll/cursor state is stored in a separate JSON file
347/// at `$XDG_DATA_HOME/fresh/file_states/{encoded_path}.json`.
348/// This allows concurrent editors to safely update different files without
349/// conflicts.
350#[derive(Debug, Clone, Serialize, Deserialize)]
351pub struct PersistedFileState {
352    /// Schema version for future migrations
353    pub version: u32,
354
355    /// The file state (cursor, scroll, etc.)
356    pub state: SerializedFileState,
357
358    /// Timestamp when last saved (Unix epoch seconds)
359    pub saved_at: u64,
360}
361
362impl PersistedFileState {
363    fn new(state: SerializedFileState) -> Self {
364        Self {
365            version: FILE_WORKSPACE_VERSION,
366            state,
367            saved_at: SystemTime::now()
368                .duration_since(UNIX_EPOCH)
369                .unwrap_or_default()
370                .as_secs(),
371        }
372    }
373}
374
375/// Per-file workspace storage for scroll/cursor positions
376///
377/// Unlike project workspaces which store file states relative to a working directory,
378/// this stores file states by absolute path so they persist across projects.
379/// This means opening the same file from different projects (or without a project)
380/// will restore the same scroll/cursor position.
381///
382/// Each file's state is stored in a separate JSON file at
383/// `$XDG_DATA_HOME/fresh/file_states/{encoded_path}.json` to avoid conflicts
384/// between concurrent editors. States are loaded lazily when opening files
385/// and saved immediately when closing files or saving the workspace.
386pub struct PersistedFileWorkspace;
387
388impl PersistedFileWorkspace {
389    /// Get the directory for file state files
390    fn states_dir() -> io::Result<PathBuf> {
391        Ok(get_data_dir()?.join("file_states"))
392    }
393
394    /// Get the state file path for a source file
395    fn state_file_path(source_path: &Path) -> io::Result<PathBuf> {
396        let canonical = source_path
397            .canonicalize()
398            .unwrap_or_else(|_| source_path.to_path_buf());
399        let filename = format!("{}.json", encode_path_for_filename(&canonical));
400        Ok(Self::states_dir()?.join(filename))
401    }
402
403    /// Load the state for a file by its absolute path (from disk)
404    pub fn load(path: &Path) -> Option<SerializedFileState> {
405        let state_path = match Self::state_file_path(path) {
406            Ok(p) => p,
407            Err(_) => return None,
408        };
409
410        if !state_path.exists() {
411            return None;
412        }
413
414        let content = match std::fs::read_to_string(&state_path) {
415            Ok(c) => c,
416            Err(_) => return None,
417        };
418
419        let persisted: PersistedFileState = match serde_json::from_str(&content) {
420            Ok(p) => p,
421            Err(_) => return None,
422        };
423
424        // Check version compatibility
425        if persisted.version > FILE_WORKSPACE_VERSION {
426            return None;
427        }
428
429        Some(persisted.state)
430    }
431
432    /// Save the state for a file by its absolute path (to disk, atomic write)
433    pub fn save(path: &Path, state: SerializedFileState) {
434        let state_path = match Self::state_file_path(path) {
435            Ok(p) => p,
436            Err(e) => {
437                tracing::warn!("Failed to get state path for {:?}: {}", path, e);
438                return;
439            }
440        };
441
442        // Ensure directory exists
443        if let Some(parent) = state_path.parent() {
444            if let Err(e) = std::fs::create_dir_all(parent) {
445                tracing::warn!("Failed to create state dir: {}", e);
446                return;
447            }
448        }
449
450        let persisted = PersistedFileState::new(state);
451        let content = match serde_json::to_string_pretty(&persisted) {
452            Ok(c) => c,
453            Err(e) => {
454                tracing::warn!("Failed to serialize file state: {}", e);
455                return;
456            }
457        };
458
459        // Write atomically: temp file + rename
460        let temp_path = state_path.with_extension("json.tmp");
461
462        let write_result = (|| -> io::Result<()> {
463            let mut file = std::fs::File::create(&temp_path)?;
464            file.write_all(content.as_bytes())?;
465            file.sync_all()?;
466            std::fs::rename(&temp_path, &state_path)?;
467            Ok(())
468        })();
469
470        if let Err(e) = write_result {
471            tracing::warn!("Failed to save file state for {:?}: {}", path, e);
472        } else {
473            tracing::trace!("File state saved for {:?}", path);
474        }
475    }
476}
477
478// ============================================================================
479// Workspace file management
480// ============================================================================
481
482/// Get the workspaces directory
483pub fn get_workspaces_dir() -> io::Result<PathBuf> {
484    Ok(get_data_dir()?.join("workspaces"))
485}
486
487/// Encode a path into a filesystem-safe filename using percent encoding
488///
489/// Keeps alphanumeric chars, `-`, `.`, `_` as-is.
490/// Replaces `/` with `_` for readability.
491/// Percent-encodes other special characters as %XX.
492///
493/// Example: `/home/user/my project` -> `home_user_my%20project`
494pub fn encode_path_for_filename(path: &Path) -> String {
495    let path_str = path.to_string_lossy();
496    let mut result = String::with_capacity(path_str.len() * 2);
497
498    for c in path_str.chars() {
499        match c {
500            // Path separators become underscores for readability
501            '/' | '\\' => result.push('_'),
502            // Safe chars pass through
503            c if c.is_ascii_alphanumeric() => result.push(c),
504            '-' | '.' => result.push(c),
505            // Underscore needs special handling to avoid collision with /
506            '_' => result.push_str("%5F"),
507            // Everything else gets percent-encoded
508            c => {
509                for byte in c.to_string().as_bytes() {
510                    result.push_str(&format!("%{:02X}", byte));
511                }
512            }
513        }
514    }
515
516    // Remove leading underscores (from leading /)
517    let result = result.trim_start_matches('_').to_string();
518
519    // Collapse multiple underscores
520    let mut final_result = String::with_capacity(result.len());
521    let mut last_was_underscore = false;
522    for c in result.chars() {
523        if c == '_' {
524            if !last_was_underscore {
525                final_result.push(c);
526            }
527            last_was_underscore = true;
528        } else {
529            final_result.push(c);
530            last_was_underscore = false;
531        }
532    }
533
534    if final_result.is_empty() {
535        final_result = "root".to_string();
536    }
537
538    final_result
539}
540
541/// Decode a filename back to the original path (for debugging/tooling)
542#[allow(dead_code)]
543pub fn decode_filename_to_path(encoded: &str) -> Option<PathBuf> {
544    if encoded == "root" {
545        return Some(PathBuf::from("/"));
546    }
547
548    let mut result = String::with_capacity(encoded.len() + 1);
549    // Re-add leading slash that was stripped during encoding
550    result.push('/');
551
552    let mut chars = encoded.chars().peekable();
553
554    while let Some(c) = chars.next() {
555        if c == '%' {
556            // Read two hex digits
557            let hex: String = chars.by_ref().take(2).collect();
558            if hex.len() == 2 {
559                if let Ok(byte) = u8::from_str_radix(&hex, 16) {
560                    result.push(byte as char);
561                }
562            }
563        } else if c == '_' {
564            result.push('/');
565        } else {
566            result.push(c);
567        }
568    }
569
570    Some(PathBuf::from(result))
571}
572
573/// Get the workspace file path for a working directory
574pub fn get_workspace_path(working_dir: &Path) -> io::Result<PathBuf> {
575    let canonical = working_dir
576        .canonicalize()
577        .unwrap_or_else(|_| working_dir.to_path_buf());
578    let filename = format!("{}.json", encode_path_for_filename(&canonical));
579    Ok(get_workspaces_dir()?.join(filename))
580}
581
582/// Workspace error types
583#[derive(Debug)]
584pub enum WorkspaceError {
585    Io(anyhow::Error),
586    Json(serde_json::Error),
587    WorkdirMismatch { expected: PathBuf, found: PathBuf },
588    VersionTooNew { version: u32, max_supported: u32 },
589}
590
591impl std::fmt::Display for WorkspaceError {
592    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
593        match self {
594            Self::Io(e) => write!(f, "Workspace error: {}", e),
595            Self::Json(e) => write!(f, "JSON error: {}", e),
596            Self::WorkdirMismatch { expected, found } => {
597                write!(
598                    f,
599                    "Working directory mismatch: expected {:?}, found {:?}",
600                    expected, found
601                )
602            }
603            WorkspaceError::VersionTooNew {
604                version,
605                max_supported,
606            } => {
607                write!(
608                    f,
609                    "Workspace version {} is newer than supported (max: {})",
610                    version, max_supported
611                )
612            }
613        }
614    }
615}
616
617impl std::error::Error for WorkspaceError {
618    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
619        match self {
620            Self::Io(e) => e.source(),
621            Self::Json(e) => Some(e),
622            _ => None,
623        }
624    }
625}
626
627impl From<io::Error> for WorkspaceError {
628    fn from(e: io::Error) -> Self {
629        WorkspaceError::Io(e.into())
630    }
631}
632
633impl From<anyhow::Error> for WorkspaceError {
634    fn from(e: anyhow::Error) -> Self {
635        WorkspaceError::Io(e)
636    }
637}
638
639impl From<serde_json::Error> for WorkspaceError {
640    fn from(e: serde_json::Error) -> Self {
641        WorkspaceError::Json(e)
642    }
643}
644
645impl Workspace {
646    /// Load workspace for a working directory (if exists)
647    pub fn load(working_dir: &Path) -> Result<Option<Workspace>, WorkspaceError> {
648        let path = get_workspace_path(working_dir)?;
649        tracing::debug!("Looking for workspace at {:?}", path);
650
651        if !path.exists() {
652            tracing::debug!("Workspace file does not exist");
653            return Ok(None);
654        }
655
656        tracing::debug!("Loading workspace from {:?}", path);
657        let content = std::fs::read_to_string(&path)?;
658        let workspace: Workspace = serde_json::from_str(&content)?;
659
660        tracing::debug!(
661            "Loaded workspace: version={}, split_states={}, active_split={}",
662            workspace.version,
663            workspace.split_states.len(),
664            workspace.active_split_id
665        );
666
667        // Validate working_dir matches (canonicalize both for comparison)
668        let expected = working_dir
669            .canonicalize()
670            .unwrap_or_else(|_| working_dir.to_path_buf());
671        let found = workspace
672            .working_dir
673            .canonicalize()
674            .unwrap_or_else(|_| workspace.working_dir.clone());
675
676        if expected != found {
677            tracing::warn!(
678                "Workspace working_dir mismatch: expected {:?}, found {:?}",
679                expected,
680                found
681            );
682            return Err(WorkspaceError::WorkdirMismatch { expected, found });
683        }
684
685        // Check version compatibility
686        if workspace.version > WORKSPACE_VERSION {
687            tracing::warn!(
688                "Workspace version {} is newer than supported {}",
689                workspace.version,
690                WORKSPACE_VERSION
691            );
692            return Err(WorkspaceError::VersionTooNew {
693                version: workspace.version,
694                max_supported: WORKSPACE_VERSION,
695            });
696        }
697
698        Ok(Some(workspace))
699    }
700
701    /// Save workspace to file using atomic write (temp file + rename)
702    ///
703    /// This ensures the workspace file is never left in a corrupted state:
704    /// 1. Write to a temporary file in the same directory
705    /// 2. Sync to disk (fsync)
706    /// 3. Atomically rename to the final path
707    pub fn save(&self) -> Result<(), WorkspaceError> {
708        let path = get_workspace_path(&self.working_dir)?;
709        tracing::debug!("Saving workspace to {:?}", path);
710
711        // Ensure directory exists
712        if let Some(parent) = path.parent() {
713            std::fs::create_dir_all(parent)?;
714        }
715
716        // Serialize to JSON
717        let content = serde_json::to_string_pretty(self)?;
718        tracing::trace!("Workspace JSON size: {} bytes", content.len());
719
720        // Write atomically: temp file + rename
721        let temp_path = path.with_extension("json.tmp");
722
723        // Write to temp file
724        {
725            let mut file = std::fs::File::create(&temp_path)?;
726            file.write_all(content.as_bytes())?;
727            file.sync_all()?; // Ensure data is on disk before rename
728        }
729
730        // Atomic rename
731        std::fs::rename(&temp_path, &path)?;
732        tracing::info!("Workspace saved to {:?}", path);
733
734        Ok(())
735    }
736
737    /// Delete workspace for a working directory
738    pub fn delete(working_dir: &Path) -> Result<(), WorkspaceError> {
739        let path = get_workspace_path(working_dir)?;
740        if path.exists() {
741            std::fs::remove_file(path)?;
742        }
743        Ok(())
744    }
745
746    /// Create a new workspace with current timestamp
747    pub fn new(working_dir: PathBuf) -> Self {
748        Self {
749            version: WORKSPACE_VERSION,
750            working_dir,
751            split_layout: SerializedSplitNode::Leaf {
752                file_path: None,
753                split_id: 0,
754                label: None,
755            },
756            active_split_id: 0,
757            split_states: HashMap::new(),
758            config_overrides: WorkspaceConfigOverrides::default(),
759            file_explorer: FileExplorerState::default(),
760            histories: WorkspaceHistories::default(),
761            search_options: SearchOptions::default(),
762            bookmarks: HashMap::new(),
763            terminals: Vec::new(),
764            external_files: Vec::new(),
765            saved_at: SystemTime::now()
766                .duration_since(UNIX_EPOCH)
767                .unwrap_or_default()
768                .as_secs(),
769        }
770    }
771
772    /// Update the saved_at timestamp to now
773    pub fn touch(&mut self) {
774        self.saved_at = SystemTime::now()
775            .duration_since(UNIX_EPOCH)
776            .unwrap_or_default()
777            .as_secs();
778    }
779}
780
781#[cfg(test)]
782mod tests {
783    use super::*;
784
785    #[test]
786    fn test_workspace_path_percent_encoding() {
787        // Test basic path encoding - readable with underscores for separators
788        let encoded = encode_path_for_filename(Path::new("/home/user/project"));
789        assert_eq!(encoded, "home_user_project");
790        assert!(!encoded.contains('/')); // No slashes in encoded output
791
792        // Round-trip: encode then decode should give original path
793        let decoded = decode_filename_to_path(&encoded).unwrap();
794        assert_eq!(decoded, PathBuf::from("/home/user/project"));
795
796        // Different paths should give different encodings
797        let path1 = get_workspace_path(Path::new("/home/user/project")).unwrap();
798        let path2 = get_workspace_path(Path::new("/home/user/other")).unwrap();
799        assert_ne!(path1, path2);
800
801        // Same path should give same encoding
802        let path1_again = get_workspace_path(Path::new("/home/user/project")).unwrap();
803        assert_eq!(path1, path1_again);
804
805        // Filename should end with .json and be readable
806        let filename = path1.file_name().unwrap().to_str().unwrap();
807        assert!(filename.ends_with(".json"));
808        assert!(filename.starts_with("home_user_project"));
809    }
810
811    #[test]
812    fn test_percent_encoding_edge_cases() {
813        // Path with dashes (should pass through)
814        let encoded = encode_path_for_filename(Path::new("/home/user/my-project"));
815        assert_eq!(encoded, "home_user_my-project");
816
817        // Path with spaces (percent-encoded)
818        let encoded = encode_path_for_filename(Path::new("/home/user/my project"));
819        assert_eq!(encoded, "home_user_my%20project");
820        let decoded = decode_filename_to_path(&encoded).unwrap();
821        assert_eq!(decoded, PathBuf::from("/home/user/my project"));
822
823        // Path with underscores (percent-encoded to avoid collision with /)
824        let encoded = encode_path_for_filename(Path::new("/home/user/my_project"));
825        assert_eq!(encoded, "home_user_my%5Fproject");
826        let decoded = decode_filename_to_path(&encoded).unwrap();
827        assert_eq!(decoded, PathBuf::from("/home/user/my_project"));
828
829        // Root path
830        let encoded = encode_path_for_filename(Path::new("/"));
831        assert_eq!(encoded, "root");
832    }
833
834    #[test]
835    fn test_workspace_serialization() {
836        let workspace = Workspace::new(PathBuf::from("/home/user/test"));
837        let json = serde_json::to_string(&workspace).unwrap();
838        let restored: Workspace = serde_json::from_str(&json).unwrap();
839
840        assert_eq!(workspace.version, restored.version);
841        assert_eq!(workspace.working_dir, restored.working_dir);
842    }
843
844    #[test]
845    fn test_workspace_config_overrides_skip_none() {
846        let overrides = WorkspaceConfigOverrides::default();
847        let json = serde_json::to_string(&overrides).unwrap();
848
849        // Empty overrides should serialize to empty object
850        assert_eq!(json, "{}");
851    }
852
853    #[test]
854    fn test_workspace_config_overrides_with_values() {
855        let overrides = WorkspaceConfigOverrides {
856            line_wrap: Some(false),
857            ..Default::default()
858        };
859        let json = serde_json::to_string(&overrides).unwrap();
860
861        assert!(json.contains("line_wrap"));
862        assert!(!json.contains("line_numbers")); // None values skipped
863    }
864
865    #[test]
866    fn test_split_layout_serialization() {
867        // Create a nested split layout
868        let layout = SerializedSplitNode::Split {
869            direction: SerializedSplitDirection::Vertical,
870            first: Box::new(SerializedSplitNode::Leaf {
871                file_path: Some(PathBuf::from("src/main.rs")),
872                split_id: 1,
873                label: None,
874            }),
875            second: Box::new(SerializedSplitNode::Leaf {
876                file_path: Some(PathBuf::from("src/lib.rs")),
877                split_id: 2,
878                label: None,
879            }),
880            ratio: 0.5,
881            split_id: 0,
882        };
883
884        let json = serde_json::to_string(&layout).unwrap();
885        let restored: SerializedSplitNode = serde_json::from_str(&json).unwrap();
886
887        // Verify the restored layout matches
888        match restored {
889            SerializedSplitNode::Split {
890                direction,
891                ratio,
892                split_id,
893                ..
894            } => {
895                assert!(matches!(direction, SerializedSplitDirection::Vertical));
896                assert_eq!(ratio, 0.5);
897                assert_eq!(split_id, 0);
898            }
899            _ => panic!("Expected Split node"),
900        }
901    }
902
903    #[test]
904    fn test_file_state_serialization() {
905        let file_state = SerializedFileState {
906            cursor: SerializedCursor {
907                position: 1234,
908                anchor: Some(1000),
909                sticky_column: 15,
910            },
911            additional_cursors: vec![SerializedCursor {
912                position: 5000,
913                anchor: None,
914                sticky_column: 0,
915            }],
916            scroll: SerializedScroll {
917                top_byte: 500,
918                top_view_line_offset: 2,
919                left_column: 10,
920            },
921            view_mode: SerializedViewMode::Source,
922            compose_width: None,
923            plugin_state: HashMap::new(),
924            folds: Vec::new(),
925        };
926
927        let json = serde_json::to_string(&file_state).unwrap();
928        let restored: SerializedFileState = serde_json::from_str(&json).unwrap();
929
930        assert_eq!(restored.cursor.position, 1234);
931        assert_eq!(restored.cursor.anchor, Some(1000));
932        assert_eq!(restored.cursor.sticky_column, 15);
933        assert_eq!(restored.additional_cursors.len(), 1);
934        assert_eq!(restored.scroll.top_byte, 500);
935        assert_eq!(restored.scroll.left_column, 10);
936    }
937
938    #[test]
939    fn test_bookmark_serialization() {
940        let mut bookmarks = HashMap::new();
941        bookmarks.insert(
942            'a',
943            SerializedBookmark {
944                file_path: PathBuf::from("src/main.rs"),
945                position: 1234,
946            },
947        );
948        bookmarks.insert(
949            'b',
950            SerializedBookmark {
951                file_path: PathBuf::from("src/lib.rs"),
952                position: 5678,
953            },
954        );
955
956        let json = serde_json::to_string(&bookmarks).unwrap();
957        let restored: HashMap<char, SerializedBookmark> = serde_json::from_str(&json).unwrap();
958
959        assert_eq!(restored.len(), 2);
960        assert_eq!(restored.get(&'a').unwrap().position, 1234);
961        assert_eq!(
962            restored.get(&'b').unwrap().file_path,
963            PathBuf::from("src/lib.rs")
964        );
965    }
966
967    #[test]
968    fn test_search_options_serialization() {
969        let options = SearchOptions {
970            case_sensitive: true,
971            whole_word: true,
972            use_regex: false,
973            confirm_each: true,
974        };
975
976        let json = serde_json::to_string(&options).unwrap();
977        let restored: SearchOptions = serde_json::from_str(&json).unwrap();
978
979        assert!(restored.case_sensitive);
980        assert!(restored.whole_word);
981        assert!(!restored.use_regex);
982        assert!(restored.confirm_each);
983    }
984
985    #[test]
986    fn test_full_workspace_round_trip() {
987        let mut workspace = Workspace::new(PathBuf::from("/home/user/myproject"));
988
989        // Configure split layout
990        workspace.split_layout = SerializedSplitNode::Split {
991            direction: SerializedSplitDirection::Horizontal,
992            first: Box::new(SerializedSplitNode::Leaf {
993                file_path: Some(PathBuf::from("README.md")),
994                split_id: 1,
995                label: None,
996            }),
997            second: Box::new(SerializedSplitNode::Leaf {
998                file_path: Some(PathBuf::from("Cargo.toml")),
999                split_id: 2,
1000                label: None,
1001            }),
1002            ratio: 0.6,
1003            split_id: 0,
1004        };
1005        workspace.active_split_id = 1;
1006
1007        // Add split state
1008        workspace.split_states.insert(
1009            1,
1010            SerializedSplitViewState {
1011                open_tabs: vec![
1012                    SerializedTabRef::File(PathBuf::from("README.md")),
1013                    SerializedTabRef::File(PathBuf::from("src/lib.rs")),
1014                ],
1015                active_tab_index: Some(0),
1016                open_files: vec![PathBuf::from("README.md"), PathBuf::from("src/lib.rs")],
1017                active_file_index: 0,
1018                file_states: HashMap::new(),
1019                tab_scroll_offset: 0,
1020                view_mode: SerializedViewMode::Source,
1021                compose_width: None,
1022            },
1023        );
1024
1025        // Add bookmarks
1026        workspace.bookmarks.insert(
1027            'm',
1028            SerializedBookmark {
1029                file_path: PathBuf::from("src/main.rs"),
1030                position: 100,
1031            },
1032        );
1033
1034        // Set search options
1035        workspace.search_options.case_sensitive = true;
1036        workspace.search_options.use_regex = true;
1037
1038        // Serialize and deserialize
1039        let json = serde_json::to_string_pretty(&workspace).unwrap();
1040        let restored: Workspace = serde_json::from_str(&json).unwrap();
1041
1042        // Verify everything matches
1043        assert_eq!(restored.version, WORKSPACE_VERSION);
1044        assert_eq!(restored.working_dir, PathBuf::from("/home/user/myproject"));
1045        assert_eq!(restored.active_split_id, 1);
1046        assert!(restored.bookmarks.contains_key(&'m'));
1047        assert!(restored.search_options.case_sensitive);
1048        assert!(restored.search_options.use_regex);
1049
1050        // Verify split state
1051        let split_state = restored.split_states.get(&1).unwrap();
1052        assert_eq!(split_state.open_files.len(), 2);
1053        assert_eq!(split_state.open_files[0], PathBuf::from("README.md"));
1054    }
1055
1056    #[test]
1057    fn test_workspace_file_save_load() {
1058        use std::fs;
1059
1060        // Create a temporary directory for testing
1061        let temp_dir = std::env::temp_dir().join("fresh_workspace_test");
1062        drop(fs::remove_dir_all(&temp_dir)); // Clean up from previous runs
1063        fs::create_dir_all(&temp_dir).unwrap();
1064
1065        let workspace_path = temp_dir.join("test_workspace.json");
1066
1067        // Create a workspace
1068        let mut workspace = Workspace::new(temp_dir.clone());
1069        workspace.search_options.case_sensitive = true;
1070        workspace.bookmarks.insert(
1071            'x',
1072            SerializedBookmark {
1073                file_path: PathBuf::from("test.txt"),
1074                position: 42,
1075            },
1076        );
1077
1078        // Save it directly to test path
1079        let content = serde_json::to_string_pretty(&workspace).unwrap();
1080        let temp_path = workspace_path.with_extension("json.tmp");
1081        let mut file = std::fs::File::create(&temp_path).unwrap();
1082        std::io::Write::write_all(&mut file, content.as_bytes()).unwrap();
1083        file.sync_all().unwrap();
1084        std::fs::rename(&temp_path, &workspace_path).unwrap();
1085
1086        // Load it back
1087        let loaded_content = fs::read_to_string(&workspace_path).unwrap();
1088        let loaded: Workspace = serde_json::from_str(&loaded_content).unwrap();
1089
1090        // Verify
1091        assert_eq!(loaded.working_dir, temp_dir);
1092        assert!(loaded.search_options.case_sensitive);
1093        assert_eq!(loaded.bookmarks.get(&'x').unwrap().position, 42);
1094
1095        // Cleanup
1096        drop(fs::remove_dir_all(&temp_dir));
1097    }
1098
1099    #[test]
1100    fn test_workspace_version_check() {
1101        let workspace = Workspace::new(PathBuf::from("/test"));
1102        assert_eq!(workspace.version, WORKSPACE_VERSION);
1103
1104        // Serialize with a future version number
1105        let mut json_value: serde_json::Value = serde_json::to_value(&workspace).unwrap();
1106        json_value["version"] = serde_json::json!(999);
1107
1108        let json = serde_json::to_string(&json_value).unwrap();
1109        let restored: Workspace = serde_json::from_str(&json).unwrap();
1110
1111        // Should still deserialize, but version is 999
1112        assert_eq!(restored.version, 999);
1113    }
1114
1115    #[test]
1116    fn test_empty_workspace_histories() {
1117        let histories = WorkspaceHistories::default();
1118        let json = serde_json::to_string(&histories).unwrap();
1119
1120        // Empty histories should serialize to empty object (due to skip_serializing_if)
1121        assert_eq!(json, "{}");
1122
1123        // But should deserialize back correctly
1124        let restored: WorkspaceHistories = serde_json::from_str(&json).unwrap();
1125        assert!(restored.search.is_empty());
1126        assert!(restored.replace.is_empty());
1127    }
1128
1129    #[test]
1130    fn test_file_explorer_state() {
1131        let state = FileExplorerState {
1132            visible: true,
1133            width_percent: 0.25,
1134            expanded_dirs: vec![
1135                PathBuf::from("src"),
1136                PathBuf::from("src/app"),
1137                PathBuf::from("tests"),
1138            ],
1139            scroll_offset: 5,
1140            show_hidden: true,
1141            show_gitignored: false,
1142        };
1143
1144        let json = serde_json::to_string(&state).unwrap();
1145        let restored: FileExplorerState = serde_json::from_str(&json).unwrap();
1146
1147        assert!(restored.visible);
1148        assert_eq!(restored.width_percent, 0.25);
1149        assert_eq!(restored.expanded_dirs.len(), 3);
1150        assert_eq!(restored.scroll_offset, 5);
1151        assert!(restored.show_hidden);
1152        assert!(!restored.show_gitignored);
1153    }
1154}