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