@@ -6,14 +6,70 @@ |
| 6 | 6 | #![allow(dead_code)] |
| 7 | 7 | |
| 8 | 8 | use anyhow::Result; |
| 9 | +use serde::{Deserialize, Serialize}; |
| 9 | 10 | use std::path::{Path, PathBuf}; |
| 10 | 11 | |
| 11 | 12 | use crate::buffer::Buffer; |
| 12 | | -use crate::editor::{Cursors, History}; |
| 13 | +use crate::editor::{Cursor, Cursors, History}; |
| 13 | 14 | use crate::fuss::FussMode; |
| 14 | 15 | use crate::lsp::LspClient; |
| 15 | 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 | 73 | /// Normalized pane bounds (0.0 to 1.0) |
| 18 | 74 | /// Converted to screen coordinates at render time |
| 19 | 75 | #[derive(Debug, Clone)] |
@@ -721,8 +777,119 @@ impl Workspace { |
| 721 | 777 | return Ok(()); |
| 722 | 778 | } |
| 723 | 779 | |
| 724 | | - // TODO: Implement JSON deserialization |
| 725 | | - // For now, just return Ok - we'll add full persistence later |
| 780 | + // Read and parse JSON |
| 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 | 893 | Ok(()) |
| 727 | 894 | } |
| 728 | 895 | |
@@ -730,10 +897,67 @@ impl Workspace { |
| 730 | 897 | pub fn save(&self) -> Result<()> { |
| 731 | 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 | 961 | Ok(()) |
| 738 | 962 | } |
| 739 | 963 | |
@@ -747,6 +971,15 @@ impl Workspace { |
| 747 | 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 | 983 | /// Open a file in a new tab |
| 751 | 984 | pub fn open_file(&mut self, path: &Path) -> Result<()> { |
| 752 | 985 | // Check if file is already open in any tab's primary buffer |
@@ -781,16 +1014,29 @@ impl Workspace { |
| 781 | 1014 | let _ = self.lsp.open_document(&path_str, &content); |
| 782 | 1015 | } |
| 783 | 1016 | |
| 784 | | - self.tabs.push(tab); |
| 785 | | - self.active_tab = self.tabs.len() - 1; |
| 1017 | + // If we have exactly one empty default tab, replace it instead of adding |
| 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 | 1025 | Ok(()) |
| 787 | 1026 | } |
| 788 | 1027 | |
| 789 | 1028 | /// Open a new file (doesn't exist yet) in a new tab |
| 790 | 1029 | pub fn open_new_file(&mut self, path: &Path) -> Result<()> { |
| 791 | 1030 | let tab = Tab::new_file(path, &self.root); |
| 792 | | - self.tabs.push(tab); |
| 793 | | - self.active_tab = self.tabs.len() - 1; |
| 1031 | + |
| 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 | 1040 | Ok(()) |
| 795 | 1041 | } |
| 796 | 1042 | |