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