@@ -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 | |