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.
This commit is contained in:
parent
8fd52dd8a0
commit
183191727b
4 changed files with 309 additions and 32 deletions
29
src/cache.rs
Normal file
29
src/cache.rs
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
use crate::model::Article;
|
||||
|
||||
pub struct Cache {
|
||||
pub articles: Vec<Article>,
|
||||
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<Cache> {
|
||||
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<Article> = 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 })
|
||||
}
|
||||
|
|
@ -1,5 +1,8 @@
|
|||
mod api;
|
||||
mod app;
|
||||
mod cache;
|
||||
mod filters;
|
||||
mod preferences_dialog;
|
||||
mod article_row;
|
||||
mod credentials;
|
||||
mod login_dialog;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
287
src/window.rs
287
src/window.rs
|
|
@ -35,14 +35,14 @@ pub mod imp {
|
|||
#[template_child]
|
||||
pub toast_overlay: TemplateChild<libadwaita::ToastOverlay>,
|
||||
#[template_child]
|
||||
pub split_view: TemplateChild<libadwaita::NavigationSplitView>,
|
||||
pub paned: TemplateChild<gtk4::Paned>,
|
||||
#[template_child]
|
||||
pub sidebar_toolbar: TemplateChild<libadwaita::ToolbarView>,
|
||||
#[template_child]
|
||||
pub refresh_stack: TemplateChild<gtk4::Stack>,
|
||||
#[template_child]
|
||||
pub refresh_button: TemplateChild<gtk4::Button>,
|
||||
#[template_child]
|
||||
pub content_page: TemplateChild<libadwaita::NavigationPage>,
|
||||
#[template_child]
|
||||
pub article_menu_button: TemplateChild<gtk4::MenuButton>,
|
||||
#[template_child]
|
||||
pub sidebar_content: TemplateChild<gtk4::Stack>,
|
||||
|
|
@ -55,12 +55,14 @@ pub mod imp {
|
|||
#[template_child]
|
||||
pub error_status: TemplateChild<libadwaita::StatusPage>,
|
||||
|
||||
pub filter_rules: RefCell<Vec<crate::filters::Rule>>,
|
||||
pub api: RefCell<Option<Api>>,
|
||||
pub write_token: RefCell<Option<String>>,
|
||||
pub article_store: RefCell<Option<gio::ListStore>>,
|
||||
pub selection: RefCell<Option<gtk4::SingleSelection>>,
|
||||
pub current_article_id: RefCell<Option<String>>,
|
||||
pub mark_unread_guard: RefCell<bool>,
|
||||
pub pending_restore_id: RefCell<Option<String>>,
|
||||
}
|
||||
|
||||
#[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::<gtk4::Widget>()));
|
||||
});
|
||||
}
|
||||
|
||||
fn instance_init(obj: &glib::subclass::InitializingObject<Self>) {
|
||||
|
|
@ -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();
|
||||
.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 <html> 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::<ArticleObject>() {
|
||||
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,20 +327,141 @@ pub mod imp {
|
|||
return false; // allow initial load
|
||||
}
|
||||
|
||||
// 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(),
|
||||
_ => { open_uri(&uri); }
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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::Widget> = gtk4::prelude::GtkWindowExt::focus(&win);
|
||||
if focused.is_some_and(|w| {
|
||||
w.is::<gtk4::Text>() || w.is::<gtk4::Entry>() || w.is::<gtk4::SearchEntry>()
|
||||
}) {
|
||||
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::<ArticleObject>()
|
||||
.map(|o| o.article().id == cache.selected_id)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
select_idx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
*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<crate::model::Article> = (0..store.n_items())
|
||||
.filter_map(|i| store.item(i).and_downcast::<ArticleObject>())
|
||||
.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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
fn auto_login(&self) {
|
||||
|
|
@ -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,21 +594,60 @@ 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::<ArticleObject>()
|
||||
.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) => {
|
||||
// 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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue