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    #[serde(default)]
296    pub width_percent: f32,
297    /// Expanded directories (relative paths)
298    #[serde(default)]
299    pub expanded_dirs: Vec<PathBuf>,
300    /// Scroll offset
301    #[serde(default)]
302    pub scroll_offset: usize,
303    /// Show hidden files (fixes #569)
304    #[serde(default)]
305    pub show_hidden: bool,
306    /// Show gitignored files (fixes #569)
307    #[serde(default)]
308    pub show_gitignored: bool,
309}
310
311impl Default for FileExplorerState {
312    fn default() -> Self {
313        Self {
314            visible: false,
315            width_percent: 0.3,
316            expanded_dirs: Vec::new(),
317            scroll_offset: 0,
318            show_hidden: false,
319            show_gitignored: false,
320        }
321    }
322}
323
324/// Per-workspace input histories
325#[derive(Debug, Clone, Default, Serialize, Deserialize)]
326pub struct WorkspaceHistories {
327    #[serde(default, skip_serializing_if = "Vec::is_empty")]
328    pub search: Vec<String>,
329    #[serde(default, skip_serializing_if = "Vec::is_empty")]
330    pub replace: Vec<String>,
331    #[serde(default, skip_serializing_if = "Vec::is_empty")]
332    pub command_palette: Vec<String>,
333    #[serde(default, skip_serializing_if = "Vec::is_empty")]
334    pub goto_line: Vec<String>,
335    #[serde(default, skip_serializing_if = "Vec::is_empty")]
336    pub open_file: Vec<String>,
337}
338
339/// Search options that persist across searches within a workspace
340#[derive(Debug, Clone, Default, Serialize, Deserialize)]
341pub struct SearchOptions {
342    #[serde(default)]
343    pub case_sensitive: bool,
344    #[serde(default)]
345    pub whole_word: bool,
346    #[serde(default)]
347    pub use_regex: bool,
348    #[serde(default)]
349    pub confirm_each: bool,
350}
351
352/// Serialized bookmark (file path + byte offset)
353#[derive(Debug, Clone, Serialize, Deserialize)]
354pub struct SerializedBookmark {
355    /// File path (relative to working_dir)
356    pub file_path: PathBuf,
357    /// Byte offset position in the file
358    pub position: usize,
359}
360
361/// Reference to an open tab (file path, terminal index, or unnamed buffer)
362#[derive(Debug, Clone, Serialize, Deserialize)]
363pub enum SerializedTabRef {
364    File(PathBuf),
365    Terminal(usize),
366    /// An unnamed buffer identified by its recovery ID
367    Unnamed(String),
368}
369
370/// Persisted metadata for a terminal workspace
371#[derive(Debug, Clone, Serialize, Deserialize)]
372pub struct SerializedTerminalWorkspace {
373    pub terminal_index: usize,
374    pub cwd: Option<PathBuf>,
375    pub shell: String,
376    pub cols: u16,
377    pub rows: u16,
378    pub log_path: PathBuf,
379    pub backing_path: PathBuf,
380}
381
382// ============================================================================
383// Global file state persistence (per-file, not per-project)
384// ============================================================================
385
386/// Individual file state stored in its own file
387///
388/// Each source file's scroll/cursor state is stored in a separate JSON file
389/// at `$XDG_DATA_HOME/fresh/file_states/{encoded_path}.json`.
390/// This allows concurrent editors to safely update different files without
391/// conflicts.
392#[derive(Debug, Clone, Serialize, Deserialize)]
393pub struct PersistedFileState {
394    /// Schema version for future migrations
395    pub version: u32,
396
397    /// The file state (cursor, scroll, etc.)
398    pub state: SerializedFileState,
399
400    /// Timestamp when last saved (Unix epoch seconds)
401    pub saved_at: u64,
402}
403
404impl PersistedFileState {
405    fn new(state: SerializedFileState) -> Self {
406        Self {
407            version: FILE_WORKSPACE_VERSION,
408            state,
409            saved_at: SystemTime::now()
410                .duration_since(UNIX_EPOCH)
411                .unwrap_or_default()
412                .as_secs(),
413        }
414    }
415}
416
417/// Per-file workspace storage for scroll/cursor positions
418///
419/// Unlike project workspaces which store file states relative to a working directory,
420/// this stores file states by absolute path so they persist across projects.
421/// This means opening the same file from different projects (or without a project)
422/// will restore the same scroll/cursor position.
423///
424/// Each file's state is stored in a separate JSON file at
425/// `$XDG_DATA_HOME/fresh/file_states/{encoded_path}.json` to avoid conflicts
426/// between concurrent editors. States are loaded lazily when opening files
427/// and saved immediately when closing files or saving the workspace.
428pub struct PersistedFileWorkspace;
429
430impl PersistedFileWorkspace {
431    /// Get the directory for file state files
432    fn states_dir() -> io::Result<PathBuf> {
433        Ok(get_data_dir()?.join("file_states"))
434    }
435
436    /// Get the state file path for a source file
437    fn state_file_path(source_path: &Path) -> io::Result<PathBuf> {
438        let canonical = source_path
439            .canonicalize()
440            .unwrap_or_else(|_| source_path.to_path_buf());
441        let filename = format!("{}.json", encode_path_for_filename(&canonical));
442        Ok(Self::states_dir()?.join(filename))
443    }
444
445    /// Load the state for a file by its absolute path (from disk)
446    pub fn load(path: &Path) -> Option<SerializedFileState> {
447        let state_path = match Self::state_file_path(path) {
448            Ok(p) => p,
449            Err(_) => return None,
450        };
451
452        if !state_path.exists() {
453            return None;
454        }
455
456        let content = match std::fs::read_to_string(&state_path) {
457            Ok(c) => c,
458            Err(_) => return None,
459        };
460
461        let persisted: PersistedFileState = match serde_json::from_str(&content) {
462            Ok(p) => p,
463            Err(_) => return None,
464        };
465
466        // Check version compatibility
467        if persisted.version > FILE_WORKSPACE_VERSION {
468            return None;
469        }
470
471        Some(persisted.state)
472    }
473
474    /// Save the state for a file by its absolute path (to disk, atomic write)
475    pub fn save(path: &Path, state: SerializedFileState) {
476        let state_path = match Self::state_file_path(path) {
477            Ok(p) => p,
478            Err(e) => {
479                tracing::warn!("Failed to get state path for {:?}: {}", path, e);
480                return;
481            }
482        };
483
484        // Ensure directory exists
485        if let Some(parent) = state_path.parent() {
486            if let Err(e) = std::fs::create_dir_all(parent) {
487                tracing::warn!("Failed to create state dir: {}", e);
488                return;
489            }
490        }
491
492        let persisted = PersistedFileState::new(state);
493        let content = match serde_json::to_string_pretty(&persisted) {
494            Ok(c) => c,
495            Err(e) => {
496                tracing::warn!("Failed to serialize file state: {}", e);
497                return;
498            }
499        };
500
501        // Write atomically: temp file + rename
502        let temp_path = state_path.with_extension("json.tmp");
503
504        let write_result = (|| -> io::Result<()> {
505            let mut file = std::fs::File::create(&temp_path)?;
506            file.write_all(content.as_bytes())?;
507            file.sync_all()?;
508            std::fs::rename(&temp_path, &state_path)?;
509            Ok(())
510        })();
511
512        if let Err(e) = write_result {
513            tracing::warn!("Failed to save file state for {:?}: {}", path, e);
514        } else {
515            tracing::trace!("File state saved for {:?}", path);
516        }
517    }
518}
519
520// ============================================================================
521// Workspace file management
522// ============================================================================
523
524/// Get the workspaces directory
525pub fn get_workspaces_dir() -> io::Result<PathBuf> {
526    Ok(get_data_dir()?.join("workspaces"))
527}
528
529/// Encode a path into a filesystem-safe filename using percent encoding
530///
531/// Keeps alphanumeric chars, `-`, `.`, `_` as-is.
532/// Replaces `/` with `_` for readability.
533/// Percent-encodes other special characters as %XX.
534///
535/// Example: `/home/user/my project` -> `home_user_my%20project`
536pub fn encode_path_for_filename(path: &Path) -> String {
537    let path_str = path.to_string_lossy();
538    let mut result = String::with_capacity(path_str.len() * 2);
539
540    for c in path_str.chars() {
541        match c {
542            // Path separators become underscores for readability
543            '/' | '\\' => result.push('_'),
544            // Safe chars pass through
545            c if c.is_ascii_alphanumeric() => result.push(c),
546            '-' | '.' => result.push(c),
547            // Underscore needs special handling to avoid collision with /
548            '_' => result.push_str("%5F"),
549            // Everything else gets percent-encoded
550            c => {
551                for byte in c.to_string().as_bytes() {
552                    result.push_str(&format!("%{:02X}", byte));
553                }
554            }
555        }
556    }
557
558    // Remove leading underscores (from leading /)
559    let result = result.trim_start_matches('_').to_string();
560
561    // Collapse multiple underscores
562    let mut final_result = String::with_capacity(result.len());
563    let mut last_was_underscore = false;
564    for c in result.chars() {
565        if c == '_' {
566            if !last_was_underscore {
567                final_result.push(c);
568            }
569            last_was_underscore = true;
570        } else {
571            final_result.push(c);
572            last_was_underscore = false;
573        }
574    }
575
576    if final_result.is_empty() {
577        final_result = "root".to_string();
578    }
579
580    final_result
581}
582
583/// Decode a filename back to the original path (for debugging/tooling)
584#[allow(dead_code)]
585pub fn decode_filename_to_path(encoded: &str) -> Option<PathBuf> {
586    if encoded == "root" {
587        return Some(PathBuf::from("/"));
588    }
589
590    let mut result = String::with_capacity(encoded.len() + 1);
591    // Re-add leading slash that was stripped during encoding
592    result.push('/');
593
594    let mut chars = encoded.chars().peekable();
595
596    while let Some(c) = chars.next() {
597        if c == '%' {
598            // Read two hex digits
599            let hex: String = chars.by_ref().take(2).collect();
600            if hex.len() == 2 {
601                if let Ok(byte) = u8::from_str_radix(&hex, 16) {
602                    result.push(byte as char);
603                }
604            }
605        } else if c == '_' {
606            result.push('/');
607        } else {
608            result.push(c);
609        }
610    }
611
612    Some(PathBuf::from(result))
613}
614
615/// Get the workspace file path for a working directory
616pub fn get_workspace_path(working_dir: &Path) -> io::Result<PathBuf> {
617    let canonical = working_dir
618        .canonicalize()
619        .unwrap_or_else(|_| working_dir.to_path_buf());
620    let filename = format!("{}.json", encode_path_for_filename(&canonical));
621    Ok(get_workspaces_dir()?.join(filename))
622}
623
624/// Get the session-workspaces directory
625pub fn get_session_workspaces_dir() -> io::Result<PathBuf> {
626    Ok(get_data_dir()?.join("session-workspaces"))
627}
628
629/// Get the workspace file path for a named session
630pub fn get_session_workspace_path(session_name: &str) -> io::Result<PathBuf> {
631    let dir = get_session_workspaces_dir()?;
632    std::fs::create_dir_all(&dir)?;
633    // Sanitize session name for filesystem safety
634    let safe_name: String = session_name
635        .chars()
636        .map(|c| {
637            if c.is_alphanumeric() || c == '-' || c == '_' || c == '.' {
638                c
639            } else {
640                '_'
641            }
642        })
643        .collect();
644    Ok(dir.join(format!("{}.json", safe_name)))
645}
646
647/// Workspace error types
648#[derive(Debug)]
649pub enum WorkspaceError {
650    Io(anyhow::Error),
651    Json(serde_json::Error),
652    WorkdirMismatch { expected: PathBuf, found: PathBuf },
653    VersionTooNew { version: u32, max_supported: u32 },
654}
655
656impl std::fmt::Display for WorkspaceError {
657    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
658        match self {
659            Self::Io(e) => write!(f, "Workspace error: {}", e),
660            Self::Json(e) => write!(f, "JSON error: {}", e),
661            Self::WorkdirMismatch { expected, found } => {
662                write!(
663                    f,
664                    "Working directory mismatch: expected {:?}, found {:?}",
665                    expected, found
666                )
667            }
668            WorkspaceError::VersionTooNew {
669                version,
670                max_supported,
671            } => {
672                write!(
673                    f,
674                    "Workspace version {} is newer than supported (max: {})",
675                    version, max_supported
676                )
677            }
678        }
679    }
680}
681
682impl std::error::Error for WorkspaceError {
683    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
684        match self {
685            Self::Io(e) => e.source(),
686            Self::Json(e) => Some(e),
687            _ => None,
688        }
689    }
690}
691
692impl From<io::Error> for WorkspaceError {
693    fn from(e: io::Error) -> Self {
694        WorkspaceError::Io(e.into())
695    }
696}
697
698impl From<anyhow::Error> for WorkspaceError {
699    fn from(e: anyhow::Error) -> Self {
700        WorkspaceError::Io(e)
701    }
702}
703
704impl From<serde_json::Error> for WorkspaceError {
705    fn from(e: serde_json::Error) -> Self {
706        WorkspaceError::Json(e)
707    }
708}
709
710impl Workspace {
711    /// Load workspace for a working directory (if exists)
712    pub fn load(working_dir: &Path) -> Result<Option<Workspace>, WorkspaceError> {
713        let path = get_workspace_path(working_dir)?;
714        tracing::debug!("Looking for workspace at {:?}", path);
715
716        if !path.exists() {
717            tracing::debug!("Workspace file does not exist");
718            return Ok(None);
719        }
720
721        tracing::debug!("Loading workspace from {:?}", path);
722        let content = std::fs::read_to_string(&path)?;
723        let workspace: Workspace = serde_json::from_str(&content)?;
724
725        tracing::debug!(
726            "Loaded workspace: version={}, split_states={}, active_split={}",
727            workspace.version,
728            workspace.split_states.len(),
729            workspace.active_split_id
730        );
731
732        // Validate working_dir matches (canonicalize both for comparison)
733        let expected = working_dir
734            .canonicalize()
735            .unwrap_or_else(|_| working_dir.to_path_buf());
736        let found = workspace
737            .working_dir
738            .canonicalize()
739            .unwrap_or_else(|_| workspace.working_dir.clone());
740
741        if expected != found {
742            tracing::warn!(
743                "Workspace working_dir mismatch: expected {:?}, found {:?}",
744                expected,
745                found
746            );
747            return Err(WorkspaceError::WorkdirMismatch { expected, found });
748        }
749
750        // Check version compatibility
751        if workspace.version > WORKSPACE_VERSION {
752            tracing::warn!(
753                "Workspace version {} is newer than supported {}",
754                workspace.version,
755                WORKSPACE_VERSION
756            );
757            return Err(WorkspaceError::VersionTooNew {
758                version: workspace.version,
759                max_supported: WORKSPACE_VERSION,
760            });
761        }
762
763        Ok(Some(workspace))
764    }
765
766    /// Save workspace to file using atomic write (temp file + rename)
767    ///
768    /// This ensures the workspace file is never left in a corrupted state:
769    /// 1. Write to a temporary file in the same directory
770    /// 2. Sync to disk (fsync)
771    /// 3. Atomically rename to the final path
772    pub fn save(&self) -> Result<(), WorkspaceError> {
773        let path = get_workspace_path(&self.working_dir)?;
774        tracing::debug!("Saving workspace to {:?}", path);
775
776        // Ensure directory exists
777        if let Some(parent) = path.parent() {
778            std::fs::create_dir_all(parent)?;
779        }
780
781        // Serialize to JSON
782        let content = serde_json::to_string_pretty(self)?;
783        tracing::trace!("Workspace JSON size: {} bytes", content.len());
784
785        // Write atomically: temp file + rename
786        let temp_path = path.with_extension("json.tmp");
787
788        // Write to temp file
789        {
790            let mut file = std::fs::File::create(&temp_path)?;
791            file.write_all(content.as_bytes())?;
792            file.sync_all()?; // Ensure data is on disk before rename
793        }
794
795        // Atomic rename
796        std::fs::rename(&temp_path, &path)?;
797        tracing::info!("Workspace saved to {:?}", path);
798
799        Ok(())
800    }
801
802    /// Load workspace for a named session (if exists)
803    pub fn load_session(
804        session_name: &str,
805        working_dir: &Path,
806    ) -> Result<Option<Workspace>, WorkspaceError> {
807        let path = get_session_workspace_path(session_name)?;
808        tracing::debug!("Looking for session workspace at {:?}", path);
809
810        if !path.exists() {
811            return Ok(None);
812        }
813
814        let content = std::fs::read_to_string(&path)?;
815        let workspace: Workspace = serde_json::from_str(&content)?;
816
817        // For session workspaces, skip working_dir validation — the session
818        // always restores its own workspace regardless of CWD.
819        if workspace.version > WORKSPACE_VERSION {
820            return Err(WorkspaceError::VersionTooNew {
821                version: workspace.version,
822                max_supported: WORKSPACE_VERSION,
823            });
824        }
825
826        // If working_dir changed, log but still load (session owns its layout)
827        let found = workspace
828            .working_dir
829            .canonicalize()
830            .unwrap_or_else(|_| workspace.working_dir.clone());
831        let expected = working_dir
832            .canonicalize()
833            .unwrap_or_else(|_| working_dir.to_path_buf());
834        if expected != found {
835            tracing::info!(
836                "Session '{}' workspace was saved from {:?}, now loading from {:?}",
837                session_name,
838                found,
839                expected
840            );
841        }
842
843        Ok(Some(workspace))
844    }
845
846    /// Save workspace for a named session using atomic write
847    pub fn save_session(&self, session_name: &str) -> Result<(), WorkspaceError> {
848        let path = get_session_workspace_path(session_name)?;
849        tracing::debug!("Saving session workspace to {:?}", path);
850
851        if let Some(parent) = path.parent() {
852            std::fs::create_dir_all(parent)?;
853        }
854
855        let content = serde_json::to_string_pretty(self)?;
856        let temp_path = path.with_extension("json.tmp");
857        {
858            let mut file = std::fs::File::create(&temp_path)?;
859            file.write_all(content.as_bytes())?;
860            file.sync_all()?;
861        }
862        std::fs::rename(&temp_path, &path)?;
863        tracing::info!("Session workspace saved to {:?}", path);
864        Ok(())
865    }
866
867    /// Delete workspace for a working directory
868    pub fn delete(working_dir: &Path) -> Result<(), WorkspaceError> {
869        let path = get_workspace_path(working_dir)?;
870        if path.exists() {
871            std::fs::remove_file(path)?;
872        }
873        Ok(())
874    }
875
876    /// Create a new workspace with current timestamp
877    pub fn new(working_dir: PathBuf) -> Self {
878        Self {
879            version: WORKSPACE_VERSION,
880            working_dir,
881            split_layout: SerializedSplitNode::Leaf {
882                file_path: None,
883                split_id: 0,
884                label: None,
885                unnamed_recovery_id: None,
886            },
887            active_split_id: 0,
888            split_states: HashMap::new(),
889            config_overrides: WorkspaceConfigOverrides::default(),
890            file_explorer: FileExplorerState::default(),
891            histories: WorkspaceHistories::default(),
892            search_options: SearchOptions::default(),
893            bookmarks: HashMap::new(),
894            terminals: Vec::new(),
895            external_files: Vec::new(),
896            read_only_files: Vec::new(),
897            unnamed_buffers: Vec::new(),
898            plugin_global_state: HashMap::new(),
899            saved_at: SystemTime::now()
900                .duration_since(UNIX_EPOCH)
901                .unwrap_or_default()
902                .as_secs(),
903        }
904    }
905
906    /// Update the saved_at timestamp to now
907    pub fn touch(&mut self) {
908        self.saved_at = SystemTime::now()
909            .duration_since(UNIX_EPOCH)
910            .unwrap_or_default()
911            .as_secs();
912    }
913}
914
915#[cfg(test)]
916mod tests {
917    use super::*;
918
919    #[test]
920    fn test_workspace_path_percent_encoding() {
921        // Test basic path encoding - readable with underscores for separators
922        let encoded = encode_path_for_filename(Path::new("/home/user/project"));
923        assert_eq!(encoded, "home_user_project");
924        assert!(!encoded.contains('/')); // No slashes in encoded output
925
926        // Round-trip: encode then decode should give original path
927        let decoded = decode_filename_to_path(&encoded).unwrap();
928        assert_eq!(decoded, PathBuf::from("/home/user/project"));
929
930        // Different paths should give different encodings
931        let path1 = get_workspace_path(Path::new("/home/user/project")).unwrap();
932        let path2 = get_workspace_path(Path::new("/home/user/other")).unwrap();
933        assert_ne!(path1, path2);
934
935        // Same path should give same encoding
936        let path1_again = get_workspace_path(Path::new("/home/user/project")).unwrap();
937        assert_eq!(path1, path1_again);
938
939        // Filename should end with .json and be readable
940        let filename = path1.file_name().unwrap().to_str().unwrap();
941        assert!(filename.ends_with(".json"));
942        assert!(filename.starts_with("home_user_project"));
943    }
944
945    #[test]
946    fn test_percent_encoding_edge_cases() {
947        // Path with dashes (should pass through)
948        let encoded = encode_path_for_filename(Path::new("/home/user/my-project"));
949        assert_eq!(encoded, "home_user_my-project");
950
951        // Path with spaces (percent-encoded)
952        let encoded = encode_path_for_filename(Path::new("/home/user/my project"));
953        assert_eq!(encoded, "home_user_my%20project");
954        let decoded = decode_filename_to_path(&encoded).unwrap();
955        assert_eq!(decoded, PathBuf::from("/home/user/my project"));
956
957        // Path with underscores (percent-encoded to avoid collision with /)
958        let encoded = encode_path_for_filename(Path::new("/home/user/my_project"));
959        assert_eq!(encoded, "home_user_my%5Fproject");
960        let decoded = decode_filename_to_path(&encoded).unwrap();
961        assert_eq!(decoded, PathBuf::from("/home/user/my_project"));
962
963        // Root path
964        let encoded = encode_path_for_filename(Path::new("/"));
965        assert_eq!(encoded, "root");
966    }
967
968    #[test]
969    fn test_workspace_serialization() {
970        let workspace = Workspace::new(PathBuf::from("/home/user/test"));
971        let json = serde_json::to_string(&workspace).unwrap();
972        let restored: Workspace = serde_json::from_str(&json).unwrap();
973
974        assert_eq!(workspace.version, restored.version);
975        assert_eq!(workspace.working_dir, restored.working_dir);
976    }
977
978    #[test]
979    fn test_workspace_config_overrides_skip_none() {
980        let overrides = WorkspaceConfigOverrides::default();
981        let json = serde_json::to_string(&overrides).unwrap();
982
983        // Empty overrides should serialize to empty object
984        assert_eq!(json, "{}");
985    }
986
987    #[test]
988    fn test_workspace_config_overrides_with_values() {
989        let overrides = WorkspaceConfigOverrides {
990            line_wrap: Some(false),
991            ..Default::default()
992        };
993        let json = serde_json::to_string(&overrides).unwrap();
994
995        assert!(json.contains("line_wrap"));
996        assert!(!json.contains("line_numbers")); // None values skipped
997    }
998
999    #[test]
1000    fn test_split_layout_serialization() {
1001        // Create a nested split layout
1002        let layout = SerializedSplitNode::Split {
1003            direction: SerializedSplitDirection::Vertical,
1004            first: Box::new(SerializedSplitNode::Leaf {
1005                file_path: Some(PathBuf::from("src/main.rs")),
1006                split_id: 1,
1007                label: None,
1008                unnamed_recovery_id: None,
1009            }),
1010            second: Box::new(SerializedSplitNode::Leaf {
1011                file_path: Some(PathBuf::from("src/lib.rs")),
1012                split_id: 2,
1013                label: None,
1014                unnamed_recovery_id: None,
1015            }),
1016            ratio: 0.5,
1017            split_id: 0,
1018        };
1019
1020        let json = serde_json::to_string(&layout).unwrap();
1021        let restored: SerializedSplitNode = serde_json::from_str(&json).unwrap();
1022
1023        // Verify the restored layout matches
1024        match restored {
1025            SerializedSplitNode::Split {
1026                direction,
1027                ratio,
1028                split_id,
1029                ..
1030            } => {
1031                assert!(matches!(direction, SerializedSplitDirection::Vertical));
1032                assert_eq!(ratio, 0.5);
1033                assert_eq!(split_id, 0);
1034            }
1035            _ => panic!("Expected Split node"),
1036        }
1037    }
1038
1039    #[test]
1040    fn test_file_state_serialization() {
1041        let file_state = SerializedFileState {
1042            cursor: SerializedCursor {
1043                position: 1234,
1044                anchor: Some(1000),
1045                sticky_column: 15,
1046            },
1047            additional_cursors: vec![SerializedCursor {
1048                position: 5000,
1049                anchor: None,
1050                sticky_column: 0,
1051            }],
1052            scroll: SerializedScroll {
1053                top_byte: 500,
1054                top_view_line_offset: 2,
1055                left_column: 10,
1056            },
1057            view_mode: SerializedViewMode::Source,
1058            compose_width: None,
1059            plugin_state: HashMap::new(),
1060            folds: Vec::new(),
1061        };
1062
1063        let json = serde_json::to_string(&file_state).unwrap();
1064        let restored: SerializedFileState = serde_json::from_str(&json).unwrap();
1065
1066        assert_eq!(restored.cursor.position, 1234);
1067        assert_eq!(restored.cursor.anchor, Some(1000));
1068        assert_eq!(restored.cursor.sticky_column, 15);
1069        assert_eq!(restored.additional_cursors.len(), 1);
1070        assert_eq!(restored.scroll.top_byte, 500);
1071        assert_eq!(restored.scroll.left_column, 10);
1072    }
1073
1074    #[test]
1075    fn test_bookmark_serialization() {
1076        let mut bookmarks = HashMap::new();
1077        bookmarks.insert(
1078            'a',
1079            SerializedBookmark {
1080                file_path: PathBuf::from("src/main.rs"),
1081                position: 1234,
1082            },
1083        );
1084        bookmarks.insert(
1085            'b',
1086            SerializedBookmark {
1087                file_path: PathBuf::from("src/lib.rs"),
1088                position: 5678,
1089            },
1090        );
1091
1092        let json = serde_json::to_string(&bookmarks).unwrap();
1093        let restored: HashMap<char, SerializedBookmark> = serde_json::from_str(&json).unwrap();
1094
1095        assert_eq!(restored.len(), 2);
1096        assert_eq!(restored.get(&'a').unwrap().position, 1234);
1097        assert_eq!(
1098            restored.get(&'b').unwrap().file_path,
1099            PathBuf::from("src/lib.rs")
1100        );
1101    }
1102
1103    #[test]
1104    fn test_search_options_serialization() {
1105        let options = SearchOptions {
1106            case_sensitive: true,
1107            whole_word: true,
1108            use_regex: false,
1109            confirm_each: true,
1110        };
1111
1112        let json = serde_json::to_string(&options).unwrap();
1113        let restored: SearchOptions = serde_json::from_str(&json).unwrap();
1114
1115        assert!(restored.case_sensitive);
1116        assert!(restored.whole_word);
1117        assert!(!restored.use_regex);
1118        assert!(restored.confirm_each);
1119    }
1120
1121    #[test]
1122    fn test_full_workspace_round_trip() {
1123        let mut workspace = Workspace::new(PathBuf::from("/home/user/myproject"));
1124
1125        // Configure split layout
1126        workspace.split_layout = SerializedSplitNode::Split {
1127            direction: SerializedSplitDirection::Horizontal,
1128            first: Box::new(SerializedSplitNode::Leaf {
1129                file_path: Some(PathBuf::from("README.md")),
1130                split_id: 1,
1131                label: None,
1132                unnamed_recovery_id: None,
1133            }),
1134            second: Box::new(SerializedSplitNode::Leaf {
1135                file_path: Some(PathBuf::from("Cargo.toml")),
1136                split_id: 2,
1137                label: None,
1138                unnamed_recovery_id: None,
1139            }),
1140            ratio: 0.6,
1141            split_id: 0,
1142        };
1143        workspace.active_split_id = 1;
1144
1145        // Add split state
1146        workspace.split_states.insert(
1147            1,
1148            SerializedSplitViewState {
1149                open_tabs: vec![
1150                    SerializedTabRef::File(PathBuf::from("README.md")),
1151                    SerializedTabRef::File(PathBuf::from("src/lib.rs")),
1152                ],
1153                active_tab_index: Some(0),
1154                open_files: vec![PathBuf::from("README.md"), PathBuf::from("src/lib.rs")],
1155                active_file_index: 0,
1156                file_states: HashMap::new(),
1157                tab_scroll_offset: 0,
1158                view_mode: SerializedViewMode::Source,
1159                compose_width: None,
1160            },
1161        );
1162
1163        // Add bookmarks
1164        workspace.bookmarks.insert(
1165            'm',
1166            SerializedBookmark {
1167                file_path: PathBuf::from("src/main.rs"),
1168                position: 100,
1169            },
1170        );
1171
1172        // Set search options
1173        workspace.search_options.case_sensitive = true;
1174        workspace.search_options.use_regex = true;
1175
1176        // Serialize and deserialize
1177        let json = serde_json::to_string_pretty(&workspace).unwrap();
1178        let restored: Workspace = serde_json::from_str(&json).unwrap();
1179
1180        // Verify everything matches
1181        assert_eq!(restored.version, WORKSPACE_VERSION);
1182        assert_eq!(restored.working_dir, PathBuf::from("/home/user/myproject"));
1183        assert_eq!(restored.active_split_id, 1);
1184        assert!(restored.bookmarks.contains_key(&'m'));
1185        assert!(restored.search_options.case_sensitive);
1186        assert!(restored.search_options.use_regex);
1187
1188        // Verify split state
1189        let split_state = restored.split_states.get(&1).unwrap();
1190        assert_eq!(split_state.open_files.len(), 2);
1191        assert_eq!(split_state.open_files[0], PathBuf::from("README.md"));
1192    }
1193
1194    #[test]
1195    fn test_workspace_file_save_load() {
1196        use std::fs;
1197
1198        // Create a temporary directory for testing
1199        let temp_dir = std::env::temp_dir().join("fresh_workspace_test");
1200        drop(fs::remove_dir_all(&temp_dir)); // Clean up from previous runs
1201        fs::create_dir_all(&temp_dir).unwrap();
1202
1203        let workspace_path = temp_dir.join("test_workspace.json");
1204
1205        // Create a workspace
1206        let mut workspace = Workspace::new(temp_dir.clone());
1207        workspace.search_options.case_sensitive = true;
1208        workspace.bookmarks.insert(
1209            'x',
1210            SerializedBookmark {
1211                file_path: PathBuf::from("test.txt"),
1212                position: 42,
1213            },
1214        );
1215
1216        // Save it directly to test path
1217        let content = serde_json::to_string_pretty(&workspace).unwrap();
1218        let temp_path = workspace_path.with_extension("json.tmp");
1219        let mut file = std::fs::File::create(&temp_path).unwrap();
1220        std::io::Write::write_all(&mut file, content.as_bytes()).unwrap();
1221        file.sync_all().unwrap();
1222        std::fs::rename(&temp_path, &workspace_path).unwrap();
1223
1224        // Load it back
1225        let loaded_content = fs::read_to_string(&workspace_path).unwrap();
1226        let loaded: Workspace = serde_json::from_str(&loaded_content).unwrap();
1227
1228        // Verify
1229        assert_eq!(loaded.working_dir, temp_dir);
1230        assert!(loaded.search_options.case_sensitive);
1231        assert_eq!(loaded.bookmarks.get(&'x').unwrap().position, 42);
1232
1233        // Cleanup
1234        drop(fs::remove_dir_all(&temp_dir));
1235    }
1236
1237    #[test]
1238    fn test_workspace_version_check() {
1239        let workspace = Workspace::new(PathBuf::from("/test"));
1240        assert_eq!(workspace.version, WORKSPACE_VERSION);
1241
1242        // Serialize with a future version number
1243        let mut json_value: serde_json::Value = serde_json::to_value(&workspace).unwrap();
1244        json_value["version"] = serde_json::json!(999);
1245
1246        let json = serde_json::to_string(&json_value).unwrap();
1247        let restored: Workspace = serde_json::from_str(&json).unwrap();
1248
1249        // Should still deserialize, but version is 999
1250        assert_eq!(restored.version, 999);
1251    }
1252
1253    #[test]
1254    fn test_empty_workspace_histories() {
1255        let histories = WorkspaceHistories::default();
1256        let json = serde_json::to_string(&histories).unwrap();
1257
1258        // Empty histories should serialize to empty object (due to skip_serializing_if)
1259        assert_eq!(json, "{}");
1260
1261        // But should deserialize back correctly
1262        let restored: WorkspaceHistories = serde_json::from_str(&json).unwrap();
1263        assert!(restored.search.is_empty());
1264        assert!(restored.replace.is_empty());
1265    }
1266
1267    #[test]
1268    fn test_file_explorer_state() {
1269        let state = FileExplorerState {
1270            visible: true,
1271            width_percent: 0.25,
1272            expanded_dirs: vec![
1273                PathBuf::from("src"),
1274                PathBuf::from("src/app"),
1275                PathBuf::from("tests"),
1276            ],
1277            scroll_offset: 5,
1278            show_hidden: true,
1279            show_gitignored: false,
1280        };
1281
1282        let json = serde_json::to_string(&state).unwrap();
1283        let restored: FileExplorerState = serde_json::from_str(&json).unwrap();
1284
1285        assert!(restored.visible);
1286        assert_eq!(restored.width_percent, 0.25);
1287        assert_eq!(restored.expanded_dirs.len(), 3);
1288        assert_eq!(restored.scroll_offset, 5);
1289        assert!(restored.show_hidden);
1290        assert!(!restored.show_gitignored);
1291    }
1292}