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