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