From 183191727b7bdbcdbc1f4ae6c239359a7ddf8e9d Mon Sep 17 00:00:00 2001 From: Jeena Date: Sat, 21 Mar 2026 01:13:09 +0000 Subject: [PATCH] window: persist article list and open article across restarts On shutdown the full article list (including current read/unread state) and the ID of the open article are saved to ~/.local/share/net.jeena.FeedTheMonkey/cache.json. On next launch: - The cached articles are loaded into the list immediately, before any network request, so the sidebar is populated and the previously open article is visible without waiting for the server. - The article content is injected into the WebView once its base HTML finishes loading (LoadEvent::Finished), avoiding a race where window.setArticle() did not yet exist. - A background refresh then fetches fresh data from the server; if the previously open article still exists its selection is preserved, otherwise the first item is selected. - Network errors during a background refresh show a toast instead of replacing the visible article list with an error page. --- src/cache.rs | 29 +++++ src/main.rs | 3 + src/model.rs | 2 +- src/window.rs | 307 +++++++++++++++++++++++++++++++++++++++++++++----- 4 files changed, 309 insertions(+), 32 deletions(-) create mode 100644 src/cache.rs diff --git a/src/cache.rs b/src/cache.rs new file mode 100644 index 0000000..491cc21 --- /dev/null +++ b/src/cache.rs @@ -0,0 +1,29 @@ +use crate::model::Article; + +pub struct Cache { + pub articles: Vec
, + pub selected_id: String, +} + +pub fn save(articles: &[Article], selected_id: &str) { + let dir = glib::user_data_dir().join("net.jeena.FeedTheMonkey"); + std::fs::create_dir_all(&dir).ok(); + let data = serde_json::json!({ + "articles": articles, + "selected_id": selected_id, + }); + if let Ok(s) = serde_json::to_string(&data) { + std::fs::write(dir.join("cache.json"), s).ok(); + } +} + +pub fn load() -> Option { + let path = glib::user_data_dir() + .join("net.jeena.FeedTheMonkey") + .join("cache.json"); + let data = std::fs::read_to_string(path).ok()?; + let json: serde_json::Value = serde_json::from_str(&data).ok()?; + let articles: Vec
= serde_json::from_value(json["articles"].clone()).ok()?; + let selected_id = json["selected_id"].as_str().unwrap_or("").to_string(); + Some(Cache { articles, selected_id }) +} diff --git a/src/main.rs b/src/main.rs index 81409cf..30daa51 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,8 @@ mod api; mod app; +mod cache; +mod filters; +mod preferences_dialog; mod article_row; mod credentials; mod login_dialog; diff --git a/src/model.rs b/src/model.rs index 090729c..8b6d853 100644 --- a/src/model.rs +++ b/src/model.rs @@ -3,7 +3,7 @@ use gtk4::prelude::*; use gtk4::subclass::prelude::*; use std::cell::RefCell; -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] pub struct Article { pub id: String, pub title: String, diff --git a/src/window.rs b/src/window.rs index ed015aa..5f2f630 100644 --- a/src/window.rs +++ b/src/window.rs @@ -35,14 +35,14 @@ pub mod imp { #[template_child] pub toast_overlay: TemplateChild, #[template_child] - pub split_view: TemplateChild, + pub paned: TemplateChild, + #[template_child] + pub sidebar_toolbar: TemplateChild, #[template_child] pub refresh_stack: TemplateChild, #[template_child] pub refresh_button: TemplateChild, #[template_child] - pub content_page: TemplateChild, - #[template_child] pub article_menu_button: TemplateChild, #[template_child] pub sidebar_content: TemplateChild, @@ -55,12 +55,14 @@ pub mod imp { #[template_child] pub error_status: TemplateChild, + pub filter_rules: RefCell>, pub api: RefCell>, pub write_token: RefCell>, pub article_store: RefCell>, pub selection: RefCell>, pub current_article_id: RefCell>, pub mark_unread_guard: RefCell, + pub pending_restore_id: RefCell>, } #[glib::object_subclass] @@ -93,6 +95,13 @@ pub mod imp { win.fullscreen(); } }); + klass.install_action("win.toggle-sidebar", None, |win, _, _| { + win.imp().do_toggle_sidebar(); + }); + klass.install_action("win.preferences", None, |win, _, _| { + let dialog = crate::preferences_dialog::PreferencesDialog::new(); + dialog.present(Some(win.upcast_ref::())); + }); } fn instance_init(obj: &glib::subclass::InitializingObject) { @@ -106,6 +115,9 @@ pub mod imp { self.setup_window_state(); self.setup_list(); self.setup_webview(); + self.setup_sidebar_toggle(); + self.setup_capture_keys(); + self.restore_from_cache(); self.auto_login(); } } @@ -116,6 +128,7 @@ pub mod imp { fn setup_window_state(&self) { let settings = gio::Settings::new("net.jeena.FeedTheMonkey"); let window = self.obj(); + window.set_title(Some("FeedTheMonkey")); let w = settings.int("window-width"); let h = settings.int("window-height"); window.set_default_size(w, h); @@ -123,10 +136,19 @@ pub mod imp { window.maximize(); } - // Restore zoom + self.paned.set_position(settings.int("sidebar-width")); let zoom = settings.double("zoom-level"); self.web_view.set_zoom_level(zoom); + // Persist sidebar width while dragging (only when visible) + let s2 = settings.clone(); + let sidebar_weak = self.sidebar_toolbar.downgrade(); + self.paned.connect_notify_local(Some("position"), move |paned, _| { + if sidebar_weak.upgrade().map(|s| s.is_visible()).unwrap_or(false) { + s2.set_int("sidebar-width", paned.position()).ok(); + } + }); + let s = settings.clone(); window.connect_close_request(move |win| { if !win.is_maximized() { @@ -194,10 +216,7 @@ pub mod imp { let article = obj.article().clone(); *self.current_article_id.borrow_mut() = Some(article.id.clone()); - // Show content pane - self.content_page.set_title(&article.title); self.article_menu_button.set_visible(true); - self.split_view.set_show_content(true); // Load in webview self.load_article_in_webview(&article); @@ -205,13 +224,15 @@ pub mod imp { } fn load_article_in_webview(&self, article: &crate::model::Article) { + let rules = self.filter_rules.borrow(); + let content = crate::filters::apply(&rules, &article.id, &article.link, &article.content); let json = serde_json::json!({ "id": article.id, "title": article.title, "feed_title": article.feed_title, "link": article.link, "updated": article.published, - "content": article.content, + "content": content, "author": article.author, "unread": article.unread, }); @@ -225,18 +246,71 @@ pub mod imp { fn setup_webview(&self) { let wv = &*self.web_view; - // Load content.html from GResource - let html = String::from_utf8( - gio::resources_lookup_data( - "/net/jeena/FeedTheMonkey/html/content.html", - gio::ResourceLookupFlags::NONE, + // Load content.html from GResource, inlining the CSS so WebKit + // doesn't need to fetch it over a custom scheme. + let load = |path: &str| { + String::from_utf8( + gio::resources_lookup_data(path, gio::ResourceLookupFlags::NONE) + .unwrap() + .to_vec(), ) .unwrap() - .to_vec(), - ) - .unwrap(); + }; + let css = load("/net/jeena/FeedTheMonkey/html/content.css"); + let html = load("/net/jeena/FeedTheMonkey/html/content.html") + .replace("/*INJECT_CSS*/", &css); wv.load_html(&html, Some("feedthemonkey://localhost/")); + // Apply Adwaita color scheme: set data-dark on so CSS + // custom properties (--bg, --fg, etc.) resolve to the right values. + let style_manager = libadwaita::StyleManager::default(); + let wv_weak = wv.downgrade(); + let apply_scheme = move |sm: &libadwaita::StyleManager| { + let is_dark = sm.is_dark(); + let js = format!("setDark({})", if is_dark { "true" } else { "false" }); + if let Some(wv) = wv_weak.upgrade() { + wv.evaluate_javascript(&js, None, None, gio::Cancellable::NONE, |_| {}); + } + }; + // Apply now (after load-changed fires, see below) and on every change + let sm_clone = style_manager.clone(); + let wv_weak2 = wv.downgrade(); + let win_weak = self.obj().downgrade(); + wv.connect_load_changed(move |_, event| { + if event == webkit6::LoadEvent::Finished { + apply_scheme(&sm_clone); + // Restore cached article now that window.setArticle() exists. + if let Some(win) = win_weak.upgrade() { + let imp = win.imp(); + if let Some(id) = imp.pending_restore_id.borrow_mut().take() { + if let Some(store) = imp.article_store.borrow().as_ref() { + for i in 0..store.n_items() { + if let Some(obj) = store.item(i).and_downcast::() { + if obj.article().id == id { + let article = obj.article().clone(); + *imp.current_article_id.borrow_mut() = Some(article.id.clone()); + imp.article_menu_button.set_visible(true); + imp.load_article_in_webview(&article); + imp.content_stack.set_visible_child_name("webview"); + break; + } + } + } + } + } + } + } + }); + let wv_weak3 = wv.downgrade(); + style_manager.connect_notify_local(Some("dark"), move |sm, _| { + let is_dark = sm.is_dark(); + let js = format!("setDark({})", if is_dark { "true" } else { "false" }); + if let Some(wv) = wv_weak3.upgrade() { + wv.evaluate_javascript(&js, None, None, gio::Cancellable::NONE, |_| {}); + } + }); + let _ = wv_weak2; // suppress unused warning + // Handle navigation policy let win_weak = self.obj().downgrade(); wv.connect_decide_policy(move |_, decision, decision_type| { @@ -253,18 +327,139 @@ pub mod imp { return false; // allow initial load } - nav.ignore(); + // Handle in-page keyboard navigation commands (not user-gesture links) + if uri.starts_with("feedthemonkey:") { + nav.ignore(); + if let Some(win) = win_weak.upgrade() { + match uri.as_str() { + "feedthemonkey:previous" => win.imp().navigate_by(-1), + "feedthemonkey:next" => win.imp().navigate_by(1), + "feedthemonkey:open" => win.imp().do_open_in_browser(), + _ => {} + } + } + return true; + } - if let Some(win) = win_weak.upgrade() { - match uri.as_str() { - "feedthemonkey:previous" => win.imp().navigate_by(-1), - "feedthemonkey:next" => win.imp().navigate_by(1), - "feedthemonkey:open" => win.imp().do_open_in_browser(), - _ => { open_uri(&uri); } + // Only open external URLs in the browser when the user explicitly + // clicked a link (NavigationType::LinkClicked). Everything else — + // iframe/embed loads, programmatic navigation — stays in the WebView. + let is_link_click = nav.navigation_action() + .map(|a| a.navigation_type() == webkit6::NavigationType::LinkClicked) + .unwrap_or(false); + + if is_link_click { + nav.ignore(); + open_uri(&uri); + true + } else { + false // let iframes/embeds load normally + } + }); + } + + // ── Sidebar toggle ──────────────────────────────────────────────────── + + fn setup_sidebar_toggle(&self) {} + + fn do_toggle_sidebar(&self) { + let sidebar = &*self.sidebar_toolbar; + if sidebar.is_visible() { + sidebar.set_visible(false); + } else { + sidebar.set_visible(true); + let settings = gio::Settings::new("net.jeena.FeedTheMonkey"); + let saved = settings.int("sidebar-width"); + self.paned.set_position(if saved > 0 { saved } else { 280 }); + } + } + + // ── Capture-phase key handler ───────────────────────────────────────── + // ShortcutScope::Global doesn't reliably intercept keys when a ListView + // has focus (the list view consumes them first). A Capture-phase + // EventControllerKey on the window fires before any widget sees the event. + + fn setup_capture_keys(&self) { + let controller = gtk4::EventControllerKey::new(); + controller.set_propagation_phase(gtk4::PropagationPhase::Capture); + let win_weak = self.obj().downgrade(); + controller.connect_key_pressed(move |_, key, _, mods| { + use gtk4::gdk::Key; + if !mods.is_empty() { + return glib::Propagation::Proceed; + } + let Some(win) = win_weak.upgrade() else { + return glib::Propagation::Proceed; + }; + // Don't steal keys from text inputs (login dialog, etc.) + let focused: Option = gtk4::prelude::GtkWindowExt::focus(&win); + if focused.is_some_and(|w| { + w.is::() || w.is::() || w.is::() + }) { + return glib::Propagation::Proceed; + } + match key { + Key::Left | Key::k => { + win.imp().navigate_by(-1); + glib::Propagation::Stop + } + Key::Right | Key::j => { + win.imp().navigate_by(1); + glib::Propagation::Stop + } + _ => glib::Propagation::Proceed, + } + }); + self.obj().add_controller(controller); + } + + // ── Cache ───────────────────────────────────────────────────────────── + + fn restore_from_cache(&self) { + let Some(cache) = crate::cache::load() else { return }; + if cache.articles.is_empty() { return; } + + let store = self.article_store.borrow(); + let store = store.as_ref().unwrap(); + + for a in &cache.articles { + store.append(&ArticleObject::new(a.clone())); + } + + // Find and scroll to the previously selected item immediately so + // the list looks right, but defer loading the article into the + // WebView until the base HTML has finished loading (see setup_webview). + let mut select_idx = 0u32; + if !cache.selected_id.is_empty() { + for i in 0..store.n_items() { + if store.item(i).and_downcast::() + .map(|o| o.article().id == cache.selected_id) + .unwrap_or(false) + { + select_idx = i; + break; } } - true - }); + *self.pending_restore_id.borrow_mut() = Some(cache.selected_id); + } + + let sel = self.selection.borrow(); + let sel = sel.as_ref().unwrap(); + *self.mark_unread_guard.borrow_mut() = true; + sel.set_selected(select_idx); + self.article_list_view.scroll_to(select_idx, gtk4::ListScrollFlags::SELECT, None); + self.sidebar_content.set_visible_child_name("list"); + } + + fn save_cache(&self) { + let store = self.article_store.borrow(); + let Some(store) = store.as_ref() else { return }; + let articles: Vec = (0..store.n_items()) + .filter_map(|i| store.item(i).and_downcast::()) + .map(|o| o.article().clone()) + .collect(); + let selected_id = self.current_article_id.borrow().clone().unwrap_or_default(); + crate::cache::save(&articles, &selected_id); } // ── Login ───────────────────────────────────────────────────────────── @@ -376,9 +571,19 @@ pub mod imp { let api = self.api.borrow().clone(); let Some(api) = api else { return }; - self.refresh_stack.set_visible_child_name("spinner"); - self.sidebar_content.set_visible_child_name("loading"); + // Reload filter rules on every refresh so edits take effect immediately. + *self.filter_rules.borrow_mut() = crate::filters::load_rules(); + self.refresh_stack.set_visible_child_name("spinner"); + + // Only show the loading screen if there's nothing to show yet. + let has_articles = self.article_store.borrow() + .as_ref().map(|s| s.n_items() > 0).unwrap_or(false); + if !has_articles { + self.sidebar_content.set_visible_child_name("loading"); + } + + let saved_id = self.current_article_id.borrow().clone(); let win_weak = self.obj().downgrade(); crate::runtime::spawn( async move { api.fetch_unread().await }, @@ -389,19 +594,58 @@ pub mod imp { Ok(articles) => { let store = imp.article_store.borrow(); let store = store.as_ref().unwrap(); + let sel = imp.selection.borrow(); + let sel = sel.as_ref().unwrap(); + store.remove_all(); - for a in articles { - store.append(&ArticleObject::new(a)); + for a in &articles { + store.append(&ArticleObject::new(a.clone())); } + + // Save cache with updated article list. + let sel_id = saved_id.as_deref().unwrap_or(""); + crate::cache::save(&articles, sel_id); + if store.n_items() == 0 { imp.sidebar_content.set_visible_child_name("empty"); + *imp.current_article_id.borrow_mut() = None; + imp.article_menu_button.set_visible(false); + imp.content_stack.set_visible_child_name("empty"); } else { + // Try to re-select the same article; fall back to first. + let mut select_idx = 0u32; + if let Some(ref id) = saved_id { + for i in 0..store.n_items() { + if store.item(i).and_downcast::() + .map(|o| o.article().id == *id) + .unwrap_or(false) + { + select_idx = i; + break; + } + } + } + *imp.mark_unread_guard.borrow_mut() = true; + sel.set_selected(select_idx); + imp.article_list_view.scroll_to( + select_idx, + gtk4::ListScrollFlags::SELECT, + None, + ); imp.sidebar_content.set_visible_child_name("list"); } } Err(e) => { - imp.error_status.set_description(Some(&e)); - imp.sidebar_content.set_visible_child_name("error"); + // If we already have cached articles, just show a toast. + let has_articles = imp.article_store.borrow() + .as_ref().map(|s| s.n_items() > 0).unwrap_or(false); + if has_articles { + let toast = libadwaita::Toast::new(&format!("Refresh failed: {e}")); + imp.toast_overlay.add_toast(toast); + } else { + imp.error_status.set_description(Some(&e)); + imp.sidebar_content.set_visible_child_name("error"); + } } } imp.refresh_stack.set_visible_child_name("button"); @@ -515,6 +759,7 @@ pub mod imp { impl WindowImpl for FeedTheMonkeyWindow { fn close_request(&self) -> glib::Propagation { + self.save_cache(); // Cancel any in-flight requests by dropping the Api *self.api.borrow_mut() = None; self.parent_close_request()