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