From 51063aac44d2b7e4b6cc98063037b8a5d3b94db7 Mon Sep 17 00:00:00 2001 From: candle Date: Wed, 23 Jul 2025 12:47:26 -0400 Subject: [PATCH] added state caching --- Cargo.toml | 2 + src/app/config.rs | 7 ++ src/app/state.rs | 1 + src/app/state/config.rs | 8 ++ src/app/state/default.rs | 1 + src/app/state/editor.rs | 1 + src/app/state/lifecycle.rs | 59 ++++++----- src/app/state/state_cache.rs | 174 +++++++++++++++++++++++++++++++++ src/app/state/tabs.rs | 15 +++ src/app/tab.rs | 25 ++--- src/io.rs | 8 ++ src/ui/central_panel/editor.rs | 11 ++- src/ui/preferences_window.rs | 81 +++++++++++++-- 13 files changed, 338 insertions(+), 55 deletions(-) create mode 100644 src/app/state/state_cache.rs diff --git a/Cargo.toml b/Cargo.toml index 8ee159f..48eea41 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,9 @@ edition = "2024" eframe = "0.32" egui = "0.32" serde = { version = "1.0.219", features = ["derive"] } +serde_json = "1.0" rfd = "0.15.4" toml = "0.9.2" dirs = "6.0" libc = "0.2.174" +diffy = "0.4.2" diff --git a/src/app/config.rs b/src/app/config.rs index 246135a..4de8736 100644 --- a/src/app/config.rs +++ b/src/app/config.rs @@ -5,6 +5,8 @@ use super::theme::Theme; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Config { + #[serde(default = "default_state_cache")] + pub state_cache: bool, #[serde(default = "default_auto_hide_toolbar")] pub auto_hide_toolbar: bool, #[serde(default = "default_hide_tab_bar")] @@ -26,6 +28,10 @@ pub struct Config { // pub vim_mode: bool, } +fn default_state_cache() -> bool { + false +} + fn default_auto_hide_toolbar() -> bool { false } @@ -54,6 +60,7 @@ fn default_syntax_highlighting() -> bool { impl Default for Config { fn default() -> Self { Self { + state_cache: default_state_cache(), auto_hide_toolbar: default_auto_hide_toolbar(), hide_tab_bar: default_hide_tab_bar(), show_line_numbers: default_show_line_numbers(), diff --git a/src/app/state.rs b/src/app/state.rs index 184ac08..852b94d 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -5,6 +5,7 @@ mod editor; mod find; mod lifecycle; mod processing; +mod state_cache; mod tabs; mod ui; diff --git a/src/app/state/config.rs b/src/app/state/config.rs index 9ec8d20..adb36fd 100644 --- a/src/app/state/config.rs +++ b/src/app/state/config.rs @@ -5,6 +5,7 @@ use crate::app::theme; impl TextEditor { pub fn from_config(config: Config) -> Self { Self { + state_cache: config.state_cache, show_line_numbers: config.show_line_numbers, word_wrap: config.word_wrap, auto_hide_toolbar: config.auto_hide_toolbar, @@ -20,6 +21,12 @@ impl TextEditor { pub fn from_config_with_context(config: Config, cc: &eframe::CreationContext<'_>) -> Self { let mut editor = Self::from_config(config); + + // Load state cache if enabled + if let Err(e) = editor.load_state_cache() { + eprintln!("Failed to load state cache: {e}"); + } + theme::apply(editor.theme, &cc.egui_ctx); cc.egui_ctx.options_mut(|o| o.zoom_with_keyboard = false); @@ -46,6 +53,7 @@ impl TextEditor { pub fn get_config(&self) -> Config { Config { + state_cache: self.state_cache, auto_hide_toolbar: self.auto_hide_toolbar, show_line_numbers: self.show_line_numbers, hide_tab_bar: self.hide_tab_bar, diff --git a/src/app/state/default.rs b/src/app/state/default.rs index 9e0a6d5..3049b2a 100644 --- a/src/app/state/default.rs +++ b/src/app/state/default.rs @@ -8,6 +8,7 @@ impl Default for TextEditor { Self { tabs: vec![Tab::new_empty(1)], active_tab_index: 0, + state_cache: false, tab_counter: 1, show_about: false, show_shortcuts: false, diff --git a/src/app/state/editor.rs b/src/app/state/editor.rs index 2f00370..a93bd74 100644 --- a/src/app/state/editor.rs +++ b/src/app/state/editor.rs @@ -34,6 +34,7 @@ pub struct TextEditor { pub(crate) tabs: Vec, pub(crate) active_tab_index: usize, pub(crate) tab_counter: usize, + pub(crate) state_cache: bool, pub(crate) show_about: bool, pub(crate) show_shortcuts: bool, pub(crate) show_find: bool, diff --git a/src/app/state/lifecycle.rs b/src/app/state/lifecycle.rs index 028923a..07b53d5 100644 --- a/src/app/state/lifecycle.rs +++ b/src/app/state/lifecycle.rs @@ -15,16 +15,22 @@ impl TextEditor { } pub fn request_quit(&mut self, ctx: &egui::Context) { - if self.has_unsaved_changes() { + if self.has_unsaved_changes() && !self.state_cache { self.pending_unsaved_action = Some(UnsavedAction::Quit); } else { self.clean_quit_requested = true; + if let Err(e) = self.save_state_cache() { + eprintln!("Failed to save state cache: {e}"); + } ctx.send_viewport_cmd(egui::ViewportCommand::Close); } } pub fn force_quit(&mut self, ctx: &egui::Context) { self.force_quit_confirmed = true; + if let Err(e) = self.save_state_cache() { + eprintln!("Failed to save state cache: {e}"); + } ctx.send_viewport_cmd(egui::ViewportCommand::Close); } @@ -65,49 +71,40 @@ impl TextEditor { }; let visuals = &ctx.style().visuals; + let error_color = visuals.error_fg_color; + egui::Window::new(title) .collapsible(false) .resizable(false) .anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO) - .frame(egui::Frame { - fill: visuals.window_fill, - stroke: visuals.window_stroke, - corner_radius: egui::CornerRadius::same(8), - shadow: visuals.window_shadow, - inner_margin: egui::Margin::same(16), - outer_margin: egui::Margin::same(0), - }) .show(ctx, |ui| { - ui.vertical(|ui| { - ui.label(egui::RichText::new(&confirmation_text).size(14.0)); + ui.vertical_centered(|ui| { ui.add_space(8.0); + ui.label(egui::RichText::new(&confirmation_text).size(14.0)); + ui.add_space(4.0); for file in &files_to_list { - ui.label(egui::RichText::new(format!("• {file}")).size(18.0).weak()); + ui.label(egui::RichText::new(file).size(12.0).color(error_color)); } ui.add_space(12.0); - ui.horizontal(|ui| { - let cancel_fill = ui.visuals().widgets.inactive.bg_fill; - let cancel_stroke = ui.visuals().widgets.inactive.bg_stroke; - let cancel_button = egui::Button::new("Cancel") - .fill(cancel_fill) - .stroke(cancel_stroke); - if ui.add(cancel_button).clicked() { + ui.horizontal(|ui| { + if ui.button("Cancel").clicked() { cancel_action = true; } + ui.add_space(8.0); - let destructive_color = ui.visuals().error_fg_color; - let confirm_button = egui::Button::new(&button_text) - .fill(destructive_color) - .stroke(egui::Stroke::new(1.0, destructive_color)); - - if ui.add(confirm_button).clicked() { - close_action_now = Some(action); + if ui + .button(egui::RichText::new(&button_text).color(error_color)) + .clicked() + { + close_action_now = Some(action.to_owned()); } }); + + ui.add_space(8.0); }); }); @@ -117,9 +114,17 @@ impl TextEditor { if let Some(action) = close_action_now { match action { - UnsavedAction::Quit => self.force_quit(ctx), + UnsavedAction::Quit => { + if let Err(e) = self.save_state_cache() { + eprintln!("Failed to save state cache: {e}"); + } + self.force_quit(ctx); + } UnsavedAction::CloseTab(tab_index) => { self.close_tab(tab_index); + if let Err(e) = self.save_state_cache() { + eprintln!("Failed to save state cache: {e}"); + } } } self.pending_unsaved_action = None; diff --git a/src/app/state/state_cache.rs b/src/app/state/state_cache.rs new file mode 100644 index 0000000..f19e4f1 --- /dev/null +++ b/src/app/state/state_cache.rs @@ -0,0 +1,174 @@ +use super::editor::TextEditor; +use crate::app::tab::{Tab, compute_content_hash}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CachedTab { + pub diff: Option, + pub full_content: Option, // This is used for 'new files' that don't have a path + pub file_path: Option, + pub is_modified: bool, + pub title: String, + pub original_content_hash: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct StateCache { + pub tabs: Vec, + pub active_tab_index: usize, + pub tab_counter: usize, +} + +impl From<&Tab> for CachedTab { + fn from(tab: &Tab) -> Self { + if let Some(file_path) = &tab.file_path { + let original_content = std::fs::read_to_string(file_path).unwrap_or_default(); + let diff = if tab.is_modified { + Some(diffy::create_patch(&original_content, &tab.content).to_string()) + } else { + None + }; + + Self { + diff, + full_content: None, + file_path: tab.file_path.clone(), + is_modified: tab.is_modified, + title: tab.title.clone(), + original_content_hash: tab.original_content_hash, + } + } else { + Self { + diff: None, + full_content: Some(tab.content.clone()), + file_path: None, + is_modified: tab.is_modified, + title: tab.title.clone(), + original_content_hash: tab.original_content_hash, + } + } + } +} + +impl From for Tab { + fn from(cached: CachedTab) -> Self { + if let Some(file_path) = cached.file_path { + let original_content = std::fs::read_to_string(&file_path).unwrap_or_default(); + let current_content = if let Some(diff_str) = cached.diff { + match diffy::Patch::from_str(&diff_str) { + Ok(patch) => { + match diffy::apply(&original_content, &patch) { + Ok(content) => content, + Err(_) => { + eprintln!("Warning: Failed to apply diff for {}, using original content", + file_path.display()); + original_content + } + } + } + Err(_) => { + eprintln!("Warning: Failed to parse diff for {}, using original content", + file_path.display()); + original_content + } + } + } else { + original_content + }; + + let original_hash = compute_content_hash(&std::fs::read_to_string(&file_path).unwrap_or_default()); + let expected_hash = cached.original_content_hash; + + let mut tab = Tab::new_with_file(current_content, file_path); + tab.title = cached.title; + + if original_hash != expected_hash { + tab.is_modified = true; + } else { + tab.is_modified = cached.is_modified; + tab.original_content_hash = cached.original_content_hash; + } + + tab + } else { + let content = cached.full_content.unwrap_or_default(); + let mut tab = Tab::new_empty(1); + tab.content = content; + tab.title = cached.title; + tab.is_modified = cached.is_modified; + tab.original_content_hash = cached.original_content_hash; + tab + } + } +} + +impl TextEditor { + pub fn state_cache_path() -> Option { + let cache_dir = if let Some(cache_dir) = dirs::cache_dir() { + cache_dir.join(env!("CARGO_PKG_NAME")) + } else if let Some(home_dir) = dirs::home_dir() { + home_dir.join(".cache").join(env!("CARGO_PKG_NAME")) + } else { + return None; + }; + + Some(cache_dir.join("state.json")) + } + + pub fn load_state_cache(&mut self) -> Result<(), Box> { + if !self.state_cache { + return Ok(()); + } + + let cache_path = Self::state_cache_path().ok_or("Cannot determine cache directory")?; + + if !cache_path.exists() { + return Ok(()); + } + + let content = std::fs::read_to_string(&cache_path)?; + let state_cache: StateCache = serde_json::from_str(&content)?; + + if !state_cache.tabs.is_empty() { + self.tabs = state_cache.tabs.into_iter().map(Tab::from).collect(); + self.active_tab_index = std::cmp::min(state_cache.active_tab_index, self.tabs.len() - 1); + self.tab_counter = state_cache.tab_counter; + self.text_needs_processing = true; + } + + Ok(()) + } + + pub fn save_state_cache(&self) -> Result<(), Box> { + if !self.state_cache { + return Ok(()); + } + + let cache_path = Self::state_cache_path().ok_or("Cannot determine cache directory")?; + + if let Some(parent) = cache_path.parent() { + std::fs::create_dir_all(parent)?; + } + + let state_cache = StateCache { + tabs: self.tabs.iter().map(CachedTab::from).collect(), + active_tab_index: self.active_tab_index, + tab_counter: self.tab_counter, + }; + + let content = serde_json::to_string_pretty(&state_cache)?; + std::fs::write(&cache_path, content)?; + + Ok(()) + } + + pub fn clear_state_cache() -> Result<(), Box> { + if let Some(cache_path) = Self::state_cache_path() { + if cache_path.exists() { + std::fs::remove_file(cache_path)?; + } + } + Ok(()) + } +} \ No newline at end of file diff --git a/src/app/state/tabs.rs b/src/app/state/tabs.rs index 7df0611..bfaba5e 100644 --- a/src/app/state/tabs.rs +++ b/src/app/state/tabs.rs @@ -18,6 +18,11 @@ impl TextEditor { self.update_find_matches(); } self.text_needs_processing = true; + + // Save state cache after adding new tab + if let Err(e) = self.save_state_cache() { + eprintln!("Failed to save state cache: {e}"); + } } pub fn close_tab(&mut self, tab_index: usize) { @@ -32,6 +37,11 @@ impl TextEditor { self.update_find_matches(); } self.text_needs_processing = true; + + // Save state cache after closing tab + if let Err(e) = self.save_state_cache() { + eprintln!("Failed to save state cache: {e}"); + } } } @@ -42,6 +52,11 @@ impl TextEditor { self.update_find_matches(); } self.text_needs_processing = true; + + // Save state cache after switching tabs + if let Err(e) = self.save_state_cache() { + eprintln!("Failed to save state cache: {e}"); + } } } } diff --git a/src/app/tab.rs b/src/app/tab.rs index 2e34888..df59360 100644 --- a/src/app/tab.rs +++ b/src/app/tab.rs @@ -2,8 +2,9 @@ use std::collections::hash_map::DefaultHasher; use std::hash::{Hash, Hasher}; use std::path::PathBuf; -pub fn compute_content_hash(content: &str, hasher: &mut DefaultHasher) -> u64 { - content.hash(hasher); +pub fn compute_content_hash(content: &str) -> u64 { + let mut hasher = DefaultHasher::new(); + content.hash(&mut hasher); hasher.finish() } @@ -11,26 +12,21 @@ pub fn compute_content_hash(content: &str, hasher: &mut DefaultHasher) -> u64 { pub struct Tab { pub content: String, pub original_content_hash: u64, - pub last_content_hash: u64, pub file_path: Option, pub is_modified: bool, pub title: String, - pub hasher: DefaultHasher, } impl Tab { pub fn new_empty(tab_number: usize) -> Self { let content = String::new(); - let mut hasher = DefaultHasher::new(); - let hash = compute_content_hash(&content, &mut hasher); + let hash = compute_content_hash(&content); Self { original_content_hash: hash, - last_content_hash: hash, content, file_path: None, is_modified: false, title: format!("new_{tab_number}"), - hasher, } } @@ -41,16 +37,13 @@ impl Tab { .unwrap_or("Untitled") .to_string(); - let mut hasher = DefaultHasher::new(); - let hash = compute_content_hash(&content, &mut hasher); + let hash = compute_content_hash(&content); Self { original_content_hash: hash, - last_content_hash: hash, content, file_path: Some(file_path), is_modified: false, title, - hasher, } } @@ -63,15 +56,13 @@ impl Tab { if self.title.starts_with("new_") { self.is_modified = !self.content.is_empty(); } else { - let current_hash = compute_content_hash(&self.content, &mut self.hasher); - self.is_modified = current_hash != self.last_content_hash; - self.last_content_hash = current_hash; + let current_hash = compute_content_hash(&self.content); + self.is_modified = current_hash != self.original_content_hash; } } pub fn mark_as_saved(&mut self) { - self.original_content_hash = compute_content_hash(&self.content, &mut self.hasher); - self.last_content_hash = self.original_content_hash; + self.original_content_hash = compute_content_hash(&self.content); self.is_modified = false; } } diff --git a/src/io.rs b/src/io.rs index e78e1bd..d2ec674 100644 --- a/src/io.rs +++ b/src/io.rs @@ -43,6 +43,10 @@ pub(crate) fn open_file(app: &mut TextEditor) { if app.show_find && !app.find_query.is_empty() { app.update_find_matches(); } + + if let Err(e) = app.save_state_cache() { + eprintln!("Failed to save state cache: {e}"); + } } Err(err) => { eprintln!("Failed to open file: {err}"); @@ -81,6 +85,10 @@ pub(crate) fn save_to_path(app: &mut TextEditor, path: PathBuf) { active_tab.file_path = Some(path.to_path_buf()); active_tab.title = title.to_string(); active_tab.mark_as_saved(); + + if let Err(e) = app.save_state_cache() { + eprintln!("Failed to save state cache: {e}"); + } } Err(err) => { eprintln!("Failed to save file: {err}"); diff --git a/src/ui/central_panel/editor.rs b/src/ui/central_panel/editor.rs index acd273a..7297415 100644 --- a/src/ui/central_panel/editor.rs +++ b/src/ui/central_panel/editor.rs @@ -130,6 +130,13 @@ pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::R None }; + // Save state cache when content changes (after releasing the borrow) + if content_changed { + if let Err(e) = app.save_state_cache() { + eprintln!("Failed to save state cache: {e}"); + } + } + if content_changed && app.show_find && !app.find_query.is_empty() { app.update_find_matches(); } @@ -163,10 +170,6 @@ pub(super) fn editor_view_ui(ui: &mut egui::Ui, app: &mut TextEditor) -> egui::R app.previous_content = content.to_owned(); app.previous_cursor_char_index = current_cursor_pos; - if let Some(active_tab) = app.get_active_tab_mut() { - active_tab.last_content_hash = - crate::app::tab::compute_content_hash(&active_tab.content, &mut active_tab.hasher); - } } if app.font_settings_changed || app.text_needs_processing { diff --git a/src/ui/preferences_window.rs b/src/ui/preferences_window.rs index 3ecd285..9576d82 100644 --- a/src/ui/preferences_window.rs +++ b/src/ui/preferences_window.rs @@ -4,8 +4,8 @@ use eframe::egui; pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) { let visuals = &ctx.style().visuals; let screen_rect = ctx.screen_rect(); - let window_width = (screen_rect.width() * 0.6).min(400.0).max(300.0); - let window_height = (screen_rect.height() * 0.7).min(500.0).max(250.0); + let window_width = (screen_rect.width() * 0.6).clamp(300.0, 400.0); + let window_height = (screen_rect.height() * 0.7).clamp(250.0, 500.0); let max_size = egui::Vec2::new(window_width, window_height); egui::Window::new("Preferences") @@ -26,6 +26,75 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) { }) .show(ctx, |ui| { ui.vertical_centered(|ui| { + + ui.heading("General Settings"); + ui.add_space(8.0); + + ui.horizontal(|ui| { + if ui + .checkbox(&mut app.state_cache, "State Cache") + .on_hover_text( + "Save and restore open tabs and unsaved changes between sessions" + ) + .changed() + { + app.save_config(); + if !app.state_cache { + if let Err(e) = TextEditor::clear_state_cache() { + eprintln!("Failed to clear state cache: {e}"); + } + } + } + }); + + ui.add_space(4.0); + + ui.horizontal(|ui| { + if ui + .checkbox(&mut app.show_line_numbers, "Show Line Numbers") + .changed() + { + app.save_config(); + } + }); + + ui.add_space(4.0); + + ui.horizontal(|ui| { + if ui + .checkbox(&mut app.word_wrap, "Word Wrap") + .changed() + { + app.save_config(); + } + }); + + ui.add_space(4.0); + + ui.horizontal(|ui| { + if ui + .checkbox(&mut app.auto_hide_toolbar, "Auto Hide Toolbar") + .on_hover_text("Hide the menu bar until you move your mouse to the top") + .changed() + { + app.save_config(); + } + }); + + ui.add_space(4.0); + + ui.horizontal(|ui| { + if ui + .checkbox(&mut app.hide_tab_bar, "Hide Tab Bar") + .on_hover_text("Hide the tab bar and show tab title in menu bar instead") + .changed() + { + app.save_config(); + } + }); + + ui.add_space(12.0); + ui.separator(); ui.heading("Font Settings"); ui.add_space(8.0); @@ -60,7 +129,7 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) { }); if changed { - app.apply_font_settings_with_ui(ctx, ui); + app.apply_font_settings(ctx); } }); @@ -99,17 +168,15 @@ pub(crate) fn preferences_window(app: &mut TextEditor, ctx: &egui::Context) { let clamped_size = new_size.clamp(8.0, 32.0); if (app.font_size - clamped_size).abs() > 0.1 { app.font_size = clamped_size; - app.apply_font_settings_with_ui(ctx, ui); + app.apply_font_settings(ctx); } } app.font_size_input = None; } }); - ui.add_space(12.0); - - ui.separator(); ui.add_space(8.0); + ui.label("Preview:"); ui.add_space(4.0);