collective

Browse / github.com/storytold/photocraft / patch

feat: reopen retained browser imports from Open Recent

feat: reopen retained browser imports from Open Recent Browser imports have no native path, so the Open Recent menu cannot reopen them after the tab reloads. Retain accepted import bytes as IndexedDB Blobs behind the existing file-open service boundary. Content/name-derived identities distinguish same-name imports; a generation counter prevents clearing history from being undone by an asynchronous completion. This shares the same bounded IndexedDB opening/deadline adaptation as the other browser-retention contribution. After either merges, the other should rebase onto that shared helper. Validation evidence: - Built the maintained aggregate br

Status
open
Score
0.00
Adopters
0 other operators
Author
muttleydosomething (imported from pull request #9724, open)
Base
59cb4c49bd236df04d8e37d4764253f29ca6600a
Size
+191 −20 in 10 files
Review
pending
Shared
2026-10-28
Id
f5975e10765127ab320544d4

How it went

No other operator has reported using it yet.

Diff

raw · Cargo.lock apps/photocraft-web/Cargo.toml apps/photocraft-web/src/browser_recent.rs apps/photocraft-web/src/indexed_presets.rs apps/photocraft-web/src/main.rs apps/photocraft-web/src/web.rs crates/ui-egui/src/file_open.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/menus.rs crates/ui-egui/src/open_prompt.rs

diff --git a/Cargo.lock b/Cargo.lock--- a/Cargo.lock+++ b/Cargo.lock@@ -5328,6 +5328,7 @@ dependencies = [  "photocraft-ui-egui",  "rfd",  "serde_json",+ "sha2",  "wasm-bindgen",  "wasm-bindgen-futures",  "web-sys",diff --git a/apps/photocraft-web/Cargo.toml b/apps/photocraft-web/Cargo.toml--- a/apps/photocraft-web/Cargo.toml+++ b/apps/photocraft-web/Cargo.toml@@ -24,6 +24,7 @@ photocraft-io = { workspace = true } photocraft-codecs = { workspace = true } photocraft-doc = { workspace = true } photocraft-ui-egui = { workspace = true }+sha2 = "0.10" egui = { workspace = true } log = { workspace = true } diff --git a/apps/photocraft-web/src/browser_recent.rs b/apps/photocraft-web/src/browser_recent.rsnew file mode 100644--- /dev/null+++ b/apps/photocraft-web/src/browser_recent.rs@@ -0,0 +1,115 @@+//! Browser copies back the existing recent-file menu. A committed Blob, never a host path,+//! is the authority for reopening. The serial queue preserves the order of accepted imports.+use photocraft_ui_egui::{PhotocraftApp, RecentFileRequest};+use sha2::{Digest, Sha256};+use std::{cell::RefCell, collections::VecDeque, rc::Rc, sync::Arc};++const PREFIX: &str = "browser-recent/";++#[derive(Default)]+struct Pending {+    queue: VecDeque<(u64, RecentFileRequest)>,+    generation: u64,+}++enum Reply {+    Retained(String),+    Opened(String, Vec<u8>),+}++#[derive(Default)]+pub struct Recent {+    pending: Rc<RefCell<Pending>>,+    result: Rc<RefCell<Option<(u64, Result<Reply, String>)>>>,+    busy: bool,+}++impl Recent {+    pub fn service(&self) -> photocraft_ui_egui::RecentFilesFn {+        let pending = self.pending.clone();+        Box::new(move |request| {+            let mut pending = pending.borrow_mut();+            // Clearing/removing history must win against an already-running retention.+            if matches!(request, RecentFileRequest::Invalidate) {+                pending.generation = pending.generation.wrapping_add(1);+                pending.queue.clear();+            } else {+                let generation = pending.generation;+                pending.queue.push_back((generation, request));+            }+            Ok(())+        })+    }++    pub fn tick(&mut self, app: &mut PhotocraftApp, ctx: &egui::Context) {+        let result = self.result.borrow_mut().take();+        if let Some((generation, result)) = result {+            self.busy = false;+            // Preferences are persisted by app.logic; schedule its next pass even when the+            // user stops interacting immediately after the asynchronous copy commits.+            ctx.request_repaint();+            match result {+                Ok(Reply::Retained(key)) if generation == self.pending.borrow().generation => app.push_recent(&key),+                Ok(Reply::Retained(_)) => {}+                Ok(Reply::Opened(name, bytes)) if generation == self.pending.borrow().generation => {+                    if let Err(error) = app.open_bytes(&name, &bytes) {+                        report(app, error);+                    }+                }+                Ok(Reply::Opened(_, _)) => {}+                Err(error) if generation == self.pending.borrow().generation => report(app, error),+                Err(_) => {}+            }+        }+        if self.busy {+            return;+        }+        let next = self.pending.borrow_mut().queue.pop_front();+        let Some((generation, task)) = next else { return };+        self.busy = true;+        let result = self.result.clone();+        let wake = ctx.clone();+        wasm_bindgen_futures::spawn_local(async move {+            *result.borrow_mut() = Some((generation, perform(task).await));+            wake.request_repaint();+        });+    }+}++fn report(app: &mut PhotocraftApp, error: String) {+    let message = format!("Recent browser copy unavailable: {error}");+    app.ui.status = message.clone();+    app.ui.status_error = true;+    photocraft_ui_egui::notices::post(app, message, Vec::new(), true, None);+}++async fn perform(task: RecentFileRequest) -> Result<Reply, String> {+    let db = crate::indexed_presets::open("photocraft.recent", js_sys::Date::now() + 10_000.0).await?;+    let result = match task {+        RecentFileRequest::Remember(name, bytes) => {+            // Names and bytes both participate: identical filenames cannot overwrite each other,+            // and reopening the same retained version does not manufacture a duplicate.+            let mut hash = Sha256::new();+            hash.update((name.len() as u64).to_le_bytes());+            hash.update(name.as_bytes());+            hash.update(bytes.as_slice());+            let key = format!("{PREFIX}{:x}/{name}", hash.finalize());+            let batch = [(key.clone(), Some(Arc::<[u8]>::from(bytes.as_slice())))].into_iter().collect();+            crate::indexed_presets::write(&db, &batch).await.map(|()| Reply::Retained(key))+        }+        RecentFileRequest::Open(key) => {+            let name = key+                .strip_prefix(PREFIX)+                .and_then(|tail| tail.split_once('/'))+                .filter(|(hash, name)| hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()) && !name.is_empty())+                .map(|(_, name)| name.to_string());+            match name {+                Some(name) => crate::indexed_presets::read_file(&db, &key).await.map(|bytes| Reply::Opened(name, bytes)),+                None => Err("This entry has no retained browser copy; use File → Open.".into()),+            }+        }+        RecentFileRequest::Invalidate => Err("invalid recent request".into()),+    };+    db.close();+    result+}diff --git a/apps/photocraft-web/src/indexed_presets.rs b/apps/photocraft-web/src/indexed_presets.rs--- a/apps/photocraft-web/src/indexed_presets.rs+++ b/apps/photocraft-web/src/indexed_presets.rs@@ -75,7 +75,7 @@ fn request(request: &IdbRequest) -> Pending { }  /// A single deadline bounds the whole startup read, not ten seconds per record.-async fn before(promise: &Promise, deadline: f64) -> Result<JsValue, String> {+pub(crate) async fn before(promise: &Promise, deadline: f64) -> Result<JsValue, String> {     let window = web_sys::window().ok_or("no window")?;     let mut timer = None;     let mut callback = None;@@ -96,9 +96,10 @@ async fn before(promise: &Promise, deadline: f64) -> Result<JsValue, String> {     result } -async fn open(deadline: f64) -> Result<IdbDatabase, String> {+/// Open an isolated browser collection using the same bounded transaction machinery.+pub(crate) async fn open(name: &str, deadline: f64) -> Result<IdbDatabase, String> {     let factory = web_sys::window().ok_or("no window")?.indexed_db().map_err(js)?.ok_or("IndexedDB unavailable")?;-    let req = factory.open_with_u32(DATABASE, 1).map_err(js)?;+    let req = factory.open_with_u32(name, 1).map_err(js)?;     let mut upgrade = Listeners::new();     let upgrade_req = req.clone();     upgrade@@ -133,7 +134,7 @@ async fn open(deadline: f64) -> Result<IdbDatabase, String> {  pub async fn load() -> Result<(IdbDatabase, Bridge, Vec<String>), String> {     let deadline = js_sys::Date::now() + TIMEOUT_MS;-    let db = open(deadline).await?;+    let db = open(DATABASE, deadline).await?;     let result = read(&db, deadline).await;     match result {         Ok((bridge, warnings)) => Ok((db, bridge, warnings)),@@ -209,6 +210,19 @@ async fn read(db: &IdbDatabase, deadline: f64) -> Result<(Bridge, Vec<String>),     Ok((bridge, warnings)) } +/// Read one retained document lazily. Document sizes use the browser's own quota,+/// rather than the preset library's separate record-size limits.+pub(crate) async fn read_file(db: &IdbDatabase, key: &str) -> Result<Vec<u8>, String> {+    let deadline = js_sys::Date::now() + TIMEOUT_MS;+    let tx = db.transaction_with_str_and_mode(FILES, IdbTransactionMode::Readonly).map_err(js)?;+    let req = tx.object_store(FILES).map_err(js)?.get(&JsValue::from_str(key)).map_err(js)?;+    let pending = request(&req);+    let value = before(&pending.promise, deadline).await?;+    let blob = value.dyn_into::<Blob>().map_err(|_| "The retained copy is no longer available; use File → Open.".to_string())?;+    let buffer = before(&blob.array_buffer(), deadline).await?;+    Ok(Uint8Array::new(&buffer).to_vec())+}+ pub async fn write(db: &IdbDatabase, batch: &Batch) -> Result<(), String> {     let tx = db.transaction_with_str_and_mode(FILES, IdbTransactionMode::Readwrite).map_err(js)?;     let store = tx.object_store(FILES).map_err(js)?;diff --git a/apps/photocraft-web/src/main.rs b/apps/photocraft-web/src/main.rs--- a/apps/photocraft-web/src/main.rs+++ b/apps/photocraft-web/src/main.rs@@ -22,6 +22,9 @@ mod preset_bridge; #[cfg(target_arch = "wasm32")] mod indexed_presets; +#[cfg(target_arch = "wasm32")]+mod browser_recent;+ #[cfg(target_arch = "wasm32")] mod web; diff --git a/apps/photocraft-web/src/web.rs b/apps/photocraft-web/src/web.rs--- a/apps/photocraft-web/src/web.rs+++ b/apps/photocraft-web/src/web.rs@@ -89,7 +89,10 @@ pub fn start() {                     let (session, warnings) = crate::preset_bridge::session(presets.clone());                     preset_warnings.extend(warnings);                     let drag = listen_drag(&pen_target);-                    let mut app = PhotocraftApp::new(session, services(inbox.clone(), place_inbox.clone(), drag_pos_service(pen_target.clone(), drag)));+                    let recent = crate::browser_recent::Recent::default();+                    let mut platform = services(inbox.clone(), place_inbox.clone(), drag_pos_service(pen_target.clone(), drag));+                    platform.recent_files = Some(recent.service());+                    let mut app = PhotocraftApp::new(session, platform);                     if !preset_warnings.is_empty() {                         photocraft_ui_egui::notices::post(&mut app, i18n::t("Some brush presets could not be loaded"), preset_warnings, true, None);                     }@@ -103,7 +106,7 @@ pub fn start() {                     }                     let unsaved = Arc::new(AtomicBool::new(false));                     guard_unload(unsaved.clone());-                    Ok(Box::new(WebShell { app, inbox, place_inbox, unsaved, served, database, presets }))+                    Ok(Box::new(WebShell { app, inbox, place_inbox, unsaved, served, database, presets, recent }))                 }),             )             .await;@@ -270,6 +273,7 @@ struct WebShell {     served: Served,     database: Option<web_sys::IdbDatabase>,     presets: crate::preset_bridge::Bridge,+    recent: crate::browser_recent::Recent, }  impl WebShell {@@ -362,6 +366,7 @@ impl eframe::App for WebShell {         }         self.serve_fonts(ctx);         self.app.logic_frame(ctx, frame);+        self.recent.tick(&mut self.app, ctx);         self.save_presets(ctx);         self.app.finish_type_reset_logic(ctx);     }diff --git a/crates/ui-egui/src/file_open.rs b/crates/ui-egui/src/file_open.rs--- a/crates/ui-egui/src/file_open.rs+++ b/crates/ui-egui/src/file_open.rs@@ -147,6 +147,12 @@ impl PhotocraftApp {      #[cfg(target_arch = "wasm32")]     pub fn open_path(&mut self, path: &str) -> Result<Vec<String>, String> {+        if path.starts_with("browser-recent/")+            && let Some(recent) = self.services.recent_files.as_mut()+        {+            recent(crate::RecentFileRequest::Open(path.to_string()))?;+            return Ok(Vec::new());+        }         Err(format!("cannot open paths on the web: {path}"))     } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -339,6 +339,15 @@ impl FormatChoices {  /// Decode a file: (document, warnings about anything approximated or dropped). pub type ImportFn = Box<dyn Fn(&str, &[u8], &photocraft_io::ImportOptions) -> Result<(Document, Vec<String>), String>>;++/// Platform history requests carry browser copies, never native write-back paths.+pub enum RecentFileRequest {+    Remember(String, std::sync::Arc<Vec<u8>>),+    Open(String),+    /// Pending retention cannot undo the user's removal or clearing of the list.+    Invalidate,+}+pub type RecentFilesFn = Box<dyn FnMut(RecentFileRequest) -> Result<(), String>>; /// Encoder settings chosen in Export As (the file format comes from the name's extension). #[derive(Clone, Debug, PartialEq)] pub struct ExportSettings {@@ -567,6 +576,8 @@ pub type RightButtonDownFn = Box<dyn Fn() -> bool + Send + Sync>; /// I/O dependencies. #[derive(Default)] pub struct Services {+    /// Asynchronous recent-file persistence supplied by browser shells.+    pub recent_files: Option<RecentFilesFn>,     /// User-initiated desktop/browser pixel sampling.     pub screen_pick: Option<screen_picker::Service>,     /// Decode a file's bytes into a document (PSD, PNG, JPEG, …).@@ -1631,6 +1642,9 @@ impl PhotocraftApp {      /// File › Open Recent › Clear Recent File List.     pub fn clear_recent(&mut self) {+        if let Some(recent) = self.services.recent_files.as_mut() {+            let _ = recent(RecentFileRequest::Invalidate);+        }         self.ui.recent_files.clear();         self.session.prefs.edit(|p| p.file_handling.recent_files.clear());     }@@ -1638,6 +1652,9 @@ impl PhotocraftApp {     /// Take one file off the recent list (the Home screen's × or Remove from Recent, #2691). The     /// file stays on disk, and opening it again lists it again.     pub fn remove_recent(&mut self, path: &str) {+        if let Some(recent) = self.services.recent_files.as_mut() {+            let _ = recent(RecentFileRequest::Invalidate);+        }         self.ui.recent_files.retain(|p| p != path);         self.session.prefs.edit(|p| p.file_handling.recent_files.retain(|r| r != path));     }@@ -1700,6 +1717,8 @@ impl PhotocraftApp {         let developed = warnings.iter().any(|w| w.contains(photocraft_io::raw::DEVELOPED_NOTE));         let question = self.session.color.open_question(&doc).filter(|_| !developed);         let wants_raw = camera_raw_ui::wants_open_dialog(self, &warnings);+        // Hold the source only when the platform needs a retained browser copy.+        let keep_bytes = (wants_raw || self.services.recent_files.is_some()) && path.is_none();         open_prompt::arrive(             self,             open_prompt::Held {@@ -1709,7 +1728,9 @@ impl PhotocraftApp {                 name: name.to_string(),                 path: path.map(str::to_string),                 warnings: warnings.clone(),-                bytes: (wants_raw && path.is_none()).then(|| std::sync::Arc::new(bytes.to_vec())),+                // Keep bytes until the open prompt actually accepts the document. Preset files,+                // failed imports, and cancelled profile prompts never enter browser history.+                bytes: keep_bytes.then(|| std::sync::Arc::new(bytes.to_vec())),                 slot: None,                 question,                 policy_applied: false,diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs--- a/crates/ui-egui/src/menus.rs+++ b/crates/ui-egui/src/menus.rs@@ -278,18 +278,10 @@ pub(crate) fn invoke_unguarded(app: &mut PhotocraftApp, ctx: &egui::Context, id:             Ok(Value::Null)         }         id if id.starts_with("file.openRecent.") => {-            #[cfg(not(target_arch = "wasm32"))]-            {-                match id.rsplit('.').next().and_then(|s| s.parse::<usize>().ok()).and_then(|i| app.ui.recent_files.get(i).cloned()) {-                    Some(p) => open_path(app, &p),-                    None => Err("no such recent file".to_string()),-                }-            }-            #[cfg(target_arch = "wasm32")]-            {-                let _ = app;-                let message = "Open Recent is unavailable on the web";-                Err(tl!(message).to_string())+            // The platform loader resolves either a native path or a retained browser copy.+            match id.rsplit('.').next().and_then(|s| s.parse::<usize>().ok()).and_then(|i| app.ui.recent_files.get(i).cloned()) {+                Some(p) => open_path(app, &p),+                None => Err("no such recent file".to_string()),             }         }         "file.saveAs" => {@@ -1056,7 +1048,13 @@ pub fn menu_items(app: &PhotocraftApp) -> Vec<MenuItem> {             .iter()             .enumerate()             .map(|(i, p)| {-                let label = std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.clone());+                let mut label = std::path::Path::new(p).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| p.clone());+                // Same-name browser imports remain distinguishable while retaining their names.+                if let Some((hash, _)) = p.strip_prefix("browser-recent/").and_then(|p| p.split_once('/'))+                    && let Some(short) = hash.get(..8)+                {+                    label.push_str(&format!(" [{short}]"));+                }                 MenuItem { id: format!("file.openRecent.{i}"), label, path: rp.clone(), shortcut: None, enabled: true, checked: None, color: None }             })             .collect();diff --git a/crates/ui-egui/src/open_prompt.rs b/crates/ui-egui/src/open_prompt.rs--- a/crates/ui-egui/src/open_prompt.rs+++ b/crates/ui-egui/src/open_prompt.rs@@ -265,5 +265,12 @@ fn open(app: &mut PhotocraftApp, held: Held, action: Option<&str>) -> Result<Val     };     app.session.set_active(index);     crate::jobs_ui::after_open(app, document, &name, path.as_deref(), &warnings, bytes.as_deref().map(Vec::as_slice))?;+    if path.is_none()+        && let Some(bytes) = bytes+        && let Some(recent) = app.services.recent_files.as_mut()+    {+        // This is the accepted-open boundary shared by picker, drop and browser control.+        let _ = recent(crate::RecentFileRequest::Remember(name, bytes));+    }     Ok(json!({"document": index, "action": action})) }