Skip to main content

ui/
recent.rs

1// recent -- the app's list of recent files ("Zuletzt geƶffnet") and the system's (plat::recent)
2//
3//   recent_add(path)         a file the user opened or saved: first in the app's list (no
4//                            duplicates, at most RECENT_KEEP) and in the system's list
5//                            (plat::recent_add: Windows' recent files and jump list,
6//                            recently-used.xbel; needs the file readable under the app's
7//                            permissions)
8//   recent_files() -> [path] the app's list, newest first (a file may be gone meanwhile: the
9//                            app tells the user and calls recent_remove)
10//   recent_open(path) -> RecentOpen   a file of the list about to be opened (call it from the
11//                            user's click or key): Ready (read it now), Asking (the browser asks
12//                            for the permission again: App::command(UIC_RECENT, 1 readable /
13//                            0 refused or gone) follows, recent_reopened() names the file) or
14//                            Missing
15//   recent_reopened() -> Option<path>   the file UIC_RECENT answered for (once)
16//   recent_remove(path)      out of both lists
17//   recent_clear()           both lists emptied (the system's: this app's entries)
18//
19// The app's list is config://recent.list (one path per line, newest first), read on first use
20// and kept in memory.  OS paths are kept absolute ("C:/x/a.png", "/home/u/a.png"; a folder
21// mount's file as its OS path), so another spelling of the same file is no new entry, and as
22// plat::persistent_name gives them (a Flatpak's file from the documents portal as its lasting
23// document: one of the portal's file dialog opens again after a restart without a new
24// dialog).  Headless runs (--shot, --script, agents) never touch the file and leave the
25// system's list alone (the list lives in memory there; jfgui_RECENT redirects the system's
26// list, plat/recent.rs).
27//
28// The browser: the list is persisted in the origin's storage with every change (an emptied
29// list as an empty file: the storage keeps no deletions), so a reload starts with it.  A file
30// of the user's own (/handles/ID/NAME, Chromium's pickers) is read in at the start while the
31// browser still grants it; else recent_open asks through plat::web::reopen (the browser wants
32// the user's gesture).  Firefox and Safari have no such handles: the list stays, its files open
33// when the app kept them in the storage itself (plat::web::persist).
34// Android: the list is a file of the app's internal storage; the picker's grants are lasting
35// (takePersistableUriPermission), and a single document's grant is given up when its entry
36// leaves the list (pushed out, removed, cleared: Android keeps at most 128 / 512 of them); a
37// folder's grant stays, the app may use the folder for more.
38
39use alloc::string::String;
40use alloc::vec::Vec;
41
42use rt::sync::SpinLock;
43
44#[cfg(target_arch = "wasm32")]
45use super::UIC_RECENT;
46use super::Ui;
47
48/// how many files the app's list keeps
49pub const RECENT_KEEP: usize = 20;
50const FILE: &str = "config://recent.list";
51
52static LIST: SpinLock<Option<Vec<String>>> = SpinLock::new(None);
53
54/// the form a path is kept in: absolute OS path when it is one, else as given
55fn canon(path: &str) -> String {
56    plat::vfs::os_path(path).and_then(|p| plat::policy::normalize(&p)).unwrap_or_else(|| String::from(path))
57}
58
59/// the form a path is added in: canon, as a name that lasts across restarts
60fn lasting(path: &str) -> String {
61    plat::persistent_name(&canon(path))
62}
63
64/// what recent_open found for a file of the list
65#[derive(Clone, Copy, Debug, PartialEq, Eq)]
66#[non_exhaustive]
67pub enum RecentOpen {
68    /// the file can be read now
69    Ready,
70    /// the browser asks the user for the permission again (a file of the user's own after a
71    /// reload): App::command(UIC_RECENT, 1 readable now / 0 refused or gone) follows
72    Asking,
73    /// gone or out of reach: the app tells the user and may call recent_remove
74    Missing,
75}
76
77/// the reopen that waits for the browser's answer, the file UIC_RECENT answered for
78#[derive(Default)]
79#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
80pub(crate) struct S {
81    asking: Option<(u32, String)>,
82    answered: Option<String>,
83}
84
85/// Android: the entries that left the list give up their lasting grant (a single document the
86/// picker handed out; a folder's stays)
87#[cfg(target_os = "android")]
88fn release_dropped(old: &[String], new: &[String]) {
89    for p in old.iter().filter(|p| !new.contains(p)) {
90        if p.starts_with("content://") && p.contains("/document/") && !p.contains("/tree/") {
91            let _ = plat::web::content_release(p);
92        }
93    }
94}
95
96impl Ui {
97    /// the app's list (read from config:// on first use, not in headless runs)
98    fn recent_with<R>(&self, f: impl FnOnce(&mut Vec<String>) -> R) -> R {
99        let mut g = LIST.lock();
100        let list = g.get_or_insert_with(|| {
101            if self.headless {
102                return Vec::new();
103            }
104            let text = plat::read_file(FILE).map(|d| String::from_utf8_lossy(&d).into_owned()).unwrap_or_default();
105            text.lines().map(str::trim).filter(|l| !l.is_empty()).take(RECENT_KEEP).map(String::from).collect()
106        });
107        f(list)
108    }
109
110    fn recent_save(&self, list: &[String]) {
111        if self.headless {
112            return;
113        }
114        let mut text = String::new();
115        for p in list {
116            text.push_str(p);
117            text.push('\n');
118        }
119        // the browser's storage keeps no deletions: an empty list is an empty file there
120        if list.is_empty() && !cfg!(target_arch = "wasm32") {
121            plat::delete(FILE);
122        } else if let Some(d) = plat::vfs::app_dir(plat::vfs::APP_CONFIG) {
123            if plat::mkdir(&d) && plat::write_file(FILE, text.as_bytes()) {
124                // the browser keeps it in its storage (a reload starts with it)
125                #[cfg(target_arch = "wasm32")]
126                if let Some(os) = plat::vfs::os_path(FILE) {
127                    let _ = plat::web::persist(&os);
128                }
129            }
130        }
131    }
132
133    /// a file the user opened or saved: first in the app's recent files and in the system's
134    pub fn recent_add(&mut self, path: &str) {
135        let p = lasting(path);
136        if p.is_empty() || p.contains(['\n', '\r']) {
137            return;
138        }
139        let (old, list) = self.recent_with(|l| {
140            let old = l.clone();
141            l.retain(|x| *x != p);
142            l.insert(0, p.clone());
143            l.truncate(RECENT_KEEP);
144            (old, l.clone())
145        });
146        self.recent_save(&list);
147        self.recent_dropped(&old, &list);
148        plat::recent_add(&p);
149    }
150
151    // the entries that left the list (Android: their grants)
152    fn recent_dropped(&self, old: &[String], new: &[String]) {
153        #[cfg(target_os = "android")]
154        if !self.headless {
155            release_dropped(old, new);
156        }
157        let _ = (old, new);
158    }
159
160    /// a file of the recent list about to be opened (from the user's click or key): Ready,
161    /// Asking (the browser asks for the permission; UIC_RECENT follows) or Missing
162    pub fn recent_open(&mut self, path: &str) -> RecentOpen {
163        if plat::file_exists(path) == 1 {
164            return RecentOpen::Ready;
165        }
166        #[cfg(target_arch = "wasm32")]
167        if let Some(id) = plat::web::reopen(path) {
168            if let Some((old, _)) = self.m.recent.asking.replace((id, String::from(path))) {
169                plat::web::forget_request(old);
170            }
171            return RecentOpen::Asking;
172        }
173        RecentOpen::Missing
174    }
175
176    /// the file UIC_RECENT answered for, once
177    pub fn recent_reopened(&mut self) -> Option<String> {
178        self.m.recent.answered.take()
179    }
180
181    /// (E_WAKE) the browser answered a reopen: App::command(UIC_RECENT, 1 readable / 0 not)
182    pub(crate) fn recent_poll(&mut self) {
183        #[cfg(target_arch = "wasm32")]
184        if let Some((id, path)) = self.m.recent.asking.clone() {
185            let st = plat::web::request_status(id);
186            if st == Some(plat::web::RequestStatus::Pending) {
187                return;
188            }
189            plat::web::forget_request(id);
190            self.m.recent.asking = None;
191            let ok = st == Some(plat::web::RequestStatus::Completed) && plat::file_exists(&path) == 1;
192            self.m.recent.answered = Some(path);
193            self.cmd_run(UIC_RECENT, ok as u32);
194        }
195    }
196
197    /// the app's recent files, newest first
198    pub fn recent_files(&self) -> Vec<String> {
199        self.recent_with(|l| l.clone())
200    }
201
202    /// a file out of the app's and the system's recent files
203    pub fn recent_remove(&mut self, path: &str) {
204        let p = canon(path);
205        let (old, list) = self.recent_with(|l| {
206            let old = l.clone();
207            l.retain(|x| *x != p);
208            (old, l.clone())
209        });
210        self.recent_save(&list);
211        self.recent_dropped(&old, &list);
212        plat::recent_remove(&p);
213    }
214
215    /// the app's recent files and its entries in the system's list forgotten
216    pub fn recent_clear(&mut self) {
217        let old = self.recent_with(core::mem::take);
218        self.recent_save(&[]);
219        self.recent_dropped(&old, &[]);
220        plat::recent_clear();
221    }
222}