tenseleyflow/fackr / 55a7075

Browse files

feat: implement workspace state persistence

- Add serializable state structs (WorkspaceState, TabState, PaneState, etc.)
- Implement Workspace::save() to persist open tabs, cursor positions,
viewport scroll, and pane bounds to .fackr/workspace.json
- Implement Workspace::load() to restore workspace state on startup,
handling missing files gracefully
- Add Cursors::from_cursor() constructor for state restoration
- Call workspace.save() on editor exit
- Replace empty default tab when opening explicit file to avoid
spurious [new] tab appearing as tab 1
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
55a707540de6c85d7e0da16285972fbfa6cd5af8
Parents
2e01399
Tree
c7bece2

3 changed files

StatusFile+-
M src/editor/cursor.rs 8 0
M src/editor/state.rs 5 0
M src/workspace/state.rs 256 10
src/editor/cursor.rsmodified
@@ -175,6 +175,14 @@ impl Cursors {
175
         }
175
         }
176
     }
176
     }
177
 
177
 
178
+    /// Create a Cursors container from a single cursor
179
+    pub fn from_cursor(cursor: Cursor) -> Self {
180
+        Self {
181
+            cursors: vec![cursor],
182
+            primary: 0,
183
+        }
184
+    }
185
+
178
     /// Get the primary cursor
186
     /// Get the primary cursor
179
     pub fn primary(&self) -> &Cursor {
187
     pub fn primary(&self) -> &Cursor {
180
         &self.cursors[self.primary]
188
         &self.cursors[self.primary]
src/editor/state.rsmodified
@@ -832,6 +832,11 @@ impl Editor {
832
             }
832
             }
833
         }
833
         }
834
 
834
 
835
+        // Save workspace state before exiting
836
+        if let Err(e) = self.workspace.save() {
837
+            eprintln!("Warning: Failed to save workspace state: {}", e);
838
+        }
839
+
835
         self.screen.leave_raw_mode()?;
840
         self.screen.leave_raw_mode()?;
836
         Ok(())
841
         Ok(())
837
     }
842
     }
src/workspace/state.rsmodified
@@ -6,14 +6,70 @@
6
 #![allow(dead_code)]
6
 #![allow(dead_code)]
7
 
7
 
8
 use anyhow::Result;
8
 use anyhow::Result;
9
+use serde::{Deserialize, Serialize};
9
 use std::path::{Path, PathBuf};
10
 use std::path::{Path, PathBuf};
10
 
11
 
11
 use crate::buffer::Buffer;
12
 use crate::buffer::Buffer;
12
-use crate::editor::{Cursors, History};
13
+use crate::editor::{Cursor, Cursors, History};
13
 use crate::fuss::FussMode;
14
 use crate::fuss::FussMode;
14
 use crate::lsp::LspClient;
15
 use crate::lsp::LspClient;
15
 use crate::syntax::Highlighter;
16
 use crate::syntax::Highlighter;
16
 
17
 
18
+// ============================================================================
19
+// Serializable state structures for workspace persistence
20
+// ============================================================================
21
+
22
+/// Serializable workspace state for persistence
23
+#[derive(Debug, Serialize, Deserialize)]
24
+struct WorkspaceState {
25
+    active_tab: usize,
26
+    tabs: Vec<TabState>,
27
+}
28
+
29
+/// Serializable tab state
30
+#[derive(Debug, Serialize, Deserialize)]
31
+struct TabState {
32
+    /// Files open in this tab (by path)
33
+    files: Vec<FileState>,
34
+    /// Active pane index
35
+    active_pane: usize,
36
+    /// Pane configurations
37
+    panes: Vec<PaneState>,
38
+}
39
+
40
+/// Serializable file reference
41
+#[derive(Debug, Serialize, Deserialize)]
42
+struct FileState {
43
+    /// Path to file (None for unsaved)
44
+    path: Option<PathBuf>,
45
+    /// Whether file is outside workspace
46
+    is_orphan: bool,
47
+}
48
+
49
+/// Serializable pane state
50
+#[derive(Debug, Serialize, Deserialize)]
51
+struct PaneState {
52
+    /// Index into tab's files
53
+    buffer_idx: usize,
54
+    /// Primary cursor position
55
+    cursor_line: usize,
56
+    cursor_col: usize,
57
+    /// Viewport scroll position
58
+    viewport_line: usize,
59
+    viewport_col: usize,
60
+    /// Pane bounds (normalized 0.0-1.0)
61
+    bounds: BoundsState,
62
+}
63
+
64
+/// Serializable pane bounds
65
+#[derive(Debug, Serialize, Deserialize)]
66
+struct BoundsState {
67
+    x_start: f32,
68
+    y_start: f32,
69
+    x_end: f32,
70
+    y_end: f32,
71
+}
72
+
17
 /// Normalized pane bounds (0.0 to 1.0)
73
 /// Normalized pane bounds (0.0 to 1.0)
18
 /// Converted to screen coordinates at render time
74
 /// Converted to screen coordinates at render time
19
 #[derive(Debug, Clone)]
75
 #[derive(Debug, Clone)]
@@ -721,8 +777,119 @@ impl Workspace {
721
             return Ok(());
777
             return Ok(());
722
         }
778
         }
723
 
779
 
724
-        // TODO: Implement JSON deserialization
780
+        // Read and parse JSON
725
-        // For now, just return Ok - we'll add full persistence later
781
+        let json = std::fs::read_to_string(&state_path)?;
782
+        let state: WorkspaceState = match serde_json::from_str(&json) {
783
+            Ok(s) => s,
784
+            Err(e) => {
785
+                // If JSON is corrupted, log and continue with empty workspace
786
+                eprintln!("Warning: Failed to parse workspace.json: {}", e);
787
+                return Ok(());
788
+            }
789
+        };
790
+
791
+        // Restore tabs from state
792
+        let mut restored_tabs = Vec::new();
793
+        for tab_state in state.tabs {
794
+            // Try to open each file in the tab
795
+            let mut buffers = Vec::new();
796
+            let mut valid_buffer_map: Vec<Option<usize>> = Vec::new(); // Maps old index to new index
797
+
798
+            for file_state in &tab_state.files {
799
+                if let Some(ref path) = file_state.path {
800
+                    // Resolve path (relative or absolute based on orphan status)
801
+                    let full_path = if file_state.is_orphan {
802
+                        path.clone()
803
+                    } else {
804
+                        self.root.join(path)
805
+                    };
806
+
807
+                    // Only restore if file still exists
808
+                    if full_path.exists() {
809
+                        match BufferEntry::from_file(&full_path, &self.root) {
810
+                            Ok(entry) => {
811
+                                valid_buffer_map.push(Some(buffers.len()));
812
+                                buffers.push(entry);
813
+                            }
814
+                            Err(_) => {
815
+                                valid_buffer_map.push(None);
816
+                            }
817
+                        }
818
+                    } else {
819
+                        valid_buffer_map.push(None);
820
+                    }
821
+                } else {
822
+                    // Unsaved file - skip it (can't restore without content)
823
+                    valid_buffer_map.push(None);
824
+                }
825
+            }
826
+
827
+            // Skip tab if no files could be restored
828
+            if buffers.is_empty() {
829
+                continue;
830
+            }
831
+
832
+            // Restore panes, mapping buffer indices
833
+            let mut panes = Vec::new();
834
+            for pane_state in &tab_state.panes {
835
+                // Check if this pane's buffer was successfully loaded
836
+                if let Some(Some(new_idx)) = valid_buffer_map.get(pane_state.buffer_idx) {
837
+                    let mut pane = Pane::with_buffer_idx(*new_idx);
838
+
839
+                    // Restore cursor position (clamped to buffer bounds)
840
+                    let buffer = &buffers[*new_idx].buffer;
841
+                    let line = pane_state.cursor_line.min(buffer.line_count().saturating_sub(1));
842
+                    let col = if line < buffer.line_count() {
843
+                        pane_state.cursor_col.min(buffer.line_len(line))
844
+                    } else {
845
+                        0
846
+                    };
847
+                    pane.cursors = Cursors::from_cursor(Cursor {
848
+                        line,
849
+                        col,
850
+                        desired_col: col,
851
+                        anchor_line: line,
852
+                        anchor_col: col,
853
+                        selecting: false,
854
+                    });
855
+
856
+                    // Restore viewport
857
+                    pane.viewport_line = pane_state.viewport_line.min(buffer.line_count().saturating_sub(1));
858
+                    pane.viewport_col = pane_state.viewport_col;
859
+
860
+                    // Restore bounds
861
+                    pane.bounds = PaneBounds {
862
+                        x_start: pane_state.bounds.x_start,
863
+                        y_start: pane_state.bounds.y_start,
864
+                        x_end: pane_state.bounds.x_end,
865
+                        y_end: pane_state.bounds.y_end,
866
+                    };
867
+
868
+                    panes.push(pane);
869
+                }
870
+            }
871
+
872
+            // Ensure at least one pane exists
873
+            if panes.is_empty() {
874
+                panes.push(Pane::default());
875
+            }
876
+
877
+            // Clamp active_pane to valid range
878
+            let active_pane = tab_state.active_pane.min(panes.len().saturating_sub(1));
879
+
880
+            restored_tabs.push(Tab {
881
+                buffers,
882
+                panes,
883
+                active_pane,
884
+            });
885
+        }
886
+
887
+        // Only replace tabs if we successfully restored at least one
888
+        if !restored_tabs.is_empty() {
889
+            self.tabs = restored_tabs;
890
+            self.active_tab = state.active_tab.min(self.tabs.len().saturating_sub(1));
891
+        }
892
+
726
         Ok(())
893
         Ok(())
727
     }
894
     }
728
 
895
 
@@ -730,10 +897,67 @@ impl Workspace {
730
     pub fn save(&self) -> Result<()> {
897
     pub fn save(&self) -> Result<()> {
731
         self.init()?; // Ensure .fackr/ exists
898
         self.init()?; // Ensure .fackr/ exists
732
 
899
 
733
-        let _state_path = self.root.join(".fackr").join("workspace.json");
900
+        let state_path = self.root.join(".fackr").join("workspace.json");
901
+
902
+        // Build serializable state
903
+        let mut tabs = Vec::new();
904
+        for tab in &self.tabs {
905
+            // Collect file states
906
+            let files: Vec<FileState> = tab.buffers.iter().map(|b| {
907
+                FileState {
908
+                    path: b.path.clone(),
909
+                    is_orphan: b.is_orphan,
910
+                }
911
+            }).collect();
912
+
913
+            // Only save tabs that have at least one saved file
914
+            if files.iter().all(|f| f.path.is_none()) {
915
+                continue;
916
+            }
917
+
918
+            // Collect pane states
919
+            let panes: Vec<PaneState> = tab.panes.iter().map(|p| {
920
+                let cursor = p.cursors.primary();
921
+                PaneState {
922
+                    buffer_idx: p.buffer_idx,
923
+                    cursor_line: cursor.line,
924
+                    cursor_col: cursor.col,
925
+                    viewport_line: p.viewport_line,
926
+                    viewport_col: p.viewport_col,
927
+                    bounds: BoundsState {
928
+                        x_start: p.bounds.x_start,
929
+                        y_start: p.bounds.y_start,
930
+                        x_end: p.bounds.x_end,
931
+                        y_end: p.bounds.y_end,
932
+                    },
933
+                }
934
+            }).collect();
935
+
936
+            tabs.push(TabState {
937
+                files,
938
+                active_pane: tab.active_pane,
939
+                panes,
940
+            });
941
+        }
942
+
943
+        // Don't save if there's nothing meaningful to save
944
+        if tabs.is_empty() {
945
+            // Remove old state file if it exists
946
+            if state_path.exists() {
947
+                let _ = std::fs::remove_file(&state_path);
948
+            }
949
+            return Ok(());
950
+        }
951
+
952
+        let state = WorkspaceState {
953
+            active_tab: self.active_tab.min(tabs.len().saturating_sub(1)),
954
+            tabs,
955
+        };
956
+
957
+        // Serialize and write
958
+        let json = serde_json::to_string_pretty(&state)?;
959
+        std::fs::write(&state_path, json)?;
734
 
960
 
735
-        // TODO: Implement JSON serialization
736
-        // For now, just return Ok - we'll add full persistence later
737
         Ok(())
961
         Ok(())
738
     }
962
     }
739
 
963
 
@@ -747,6 +971,15 @@ impl Workspace {
747
         &mut self.tabs[self.active_tab]
971
         &mut self.tabs[self.active_tab]
748
     }
972
     }
749
 
973
 
974
+    /// Check if a tab is an empty default tab (no path, not modified, empty content)
975
+    fn is_empty_default_tab(tab: &mut Tab) -> bool {
976
+        if tab.buffers.len() != 1 || tab.panes.len() != 1 {
977
+            return false;
978
+        }
979
+        let buf = &mut tab.buffers[0];
980
+        buf.path.is_none() && !buf.is_modified() && buf.buffer.len_chars() == 0
981
+    }
982
+
750
     /// Open a file in a new tab
983
     /// Open a file in a new tab
751
     pub fn open_file(&mut self, path: &Path) -> Result<()> {
984
     pub fn open_file(&mut self, path: &Path) -> Result<()> {
752
         // Check if file is already open in any tab's primary buffer
985
         // Check if file is already open in any tab's primary buffer
@@ -781,16 +1014,29 @@ impl Workspace {
781
             let _ = self.lsp.open_document(&path_str, &content);
1014
             let _ = self.lsp.open_document(&path_str, &content);
782
         }
1015
         }
783
 
1016
 
784
-        self.tabs.push(tab);
1017
+        // If we have exactly one empty default tab, replace it instead of adding
785
-        self.active_tab = self.tabs.len() - 1;
1018
+        if self.tabs.len() == 1 && Self::is_empty_default_tab(&mut self.tabs[0]) {
1019
+            self.tabs[0] = tab;
1020
+            self.active_tab = 0;
1021
+        } else {
1022
+            self.tabs.push(tab);
1023
+            self.active_tab = self.tabs.len() - 1;
1024
+        }
786
         Ok(())
1025
         Ok(())
787
     }
1026
     }
788
 
1027
 
789
     /// Open a new file (doesn't exist yet) in a new tab
1028
     /// Open a new file (doesn't exist yet) in a new tab
790
     pub fn open_new_file(&mut self, path: &Path) -> Result<()> {
1029
     pub fn open_new_file(&mut self, path: &Path) -> Result<()> {
791
         let tab = Tab::new_file(path, &self.root);
1030
         let tab = Tab::new_file(path, &self.root);
792
-        self.tabs.push(tab);
1031
+
793
-        self.active_tab = self.tabs.len() - 1;
1032
+        // If we have exactly one empty default tab, replace it instead of adding
1033
+        if self.tabs.len() == 1 && Self::is_empty_default_tab(&mut self.tabs[0]) {
1034
+            self.tabs[0] = tab;
1035
+            self.active_tab = 0;
1036
+        } else {
1037
+            self.tabs.push(tab);
1038
+            self.active_tab = self.tabs.len() - 1;
1039
+        }
794
         Ok(())
1040
         Ok(())
795
     }
1041
     }
796
 
1042