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