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