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}