diff --git a/Cargo.lock b/Cargo.lock index 38b2628a..0484bd9b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -170,6 +170,15 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +[[package]] +name = "arc-swap" +version = "1.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" +dependencies = [ + "rustversion", +] + [[package]] name = "arg_enum_proc_macro" version = "0.3.4" @@ -953,6 +962,12 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cbc" version = "0.1.2" @@ -1033,6 +1048,19 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link 0.2.1", +] + [[package]] name = "cipher" version = "0.4.4" @@ -1762,6 +1790,12 @@ dependencies = [ "parking_lot_core", ] +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + [[package]] name = "data-url" version = "0.3.2" @@ -3355,6 +3389,30 @@ dependencies = [ "tracing", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.61.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "icu_collections" version = "2.3.0" @@ -4128,6 +4186,16 @@ dependencies = [ "unicase", ] +[[package]] +name = "minicov" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3aa3aa12b448ac225b3102217d1ac5cc717908f02722926524b0599c933c7a0" +dependencies = [ + "cc", + "walkdir", +] + [[package]] name = "minijinja" version = "2.24.0" @@ -4390,6 +4458,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num" version = "0.4.3" @@ -4800,6 +4877,12 @@ dependencies = [ "zvariant", ] +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "open" version = "5.4.1" @@ -5805,6 +5888,36 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rmp" +version = "0.8.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba8be72d372b2c9b35542551678538b562e7cf86c3315773cae48dfbfe7790c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "rmp-serde" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f81bee8c8ef9b577d1681a70ebbc962c232461e397b22c208c43c04b67a155" +dependencies = [ + "rmp", + "serde", +] + +[[package]] +name = "rmpv" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a4e1d4b9b938a26d2996af33229f0ca0956c652c1375067f0b45291c1df8417" +dependencies = [ + "rmp", + "serde", + "serde_bytes", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -6159,6 +6272,7 @@ dependencies = [ "raw-window-handle", "rustc-hash 2.1.3", "schist-adjustments", + "schist-cloud", "schist-codec-psd", "schist-codecs-common", "schist-color", @@ -6200,11 +6314,44 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", + "web-time", "windows 0.58.0", "windows-core 0.58.0", "x11rb", ] +[[package]] +name = "schist-cloud" +version = "0.12.0" +dependencies = [ + "anyhow", + "chrono", + "futures", + "gloo-timers", + "js-sys", + "rmp-serde", + "rmpv", + "schist-codec-psd", + "schist-color", + "schist-core", + "schist-document", + "serde", + "serde_bytes", + "serde_json", + "sha2 0.10.9", + "tempfile", + "tokio", + "tokio-tungstenite", + "ureq", + "url", + "uuid", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test", + "web-sys", + "web-time", +] + [[package]] name = "schist-codec-affinity" version = "0.12.0" @@ -6270,6 +6417,7 @@ dependencies = [ "log", "moxcms", "schist-codec-affinity", + "schist-codec-psd", "schist-codec-raw", "schist-color", "schist-colormgmt", @@ -6360,6 +6508,27 @@ dependencies = [ "smallvec", ] +[[package]] +name = "schist-document" +version = "0.12.0" +dependencies = [ + "anyhow", + "image", + "rmp-serde", + "schist-codec-psd", + "schist-codecs-common", + "schist-color", + "schist-core", + "schist-plugin-api", + "serde", + "serde_bytes", + "serde_json", + "uuid", + "wasm-bindgen-test", + "web-time", + "yrs", +] + [[package]] name = "schist-filters-core" version = "0.12.0" @@ -6447,6 +6616,18 @@ dependencies = [ "tract-onnx", ] +[[package]] +name = "schist-people-worker" +version = "0.12.0" +dependencies = [ + "anyhow", + "image", + "schist-gallery", + "schist-neural", + "serde", + "serde_json", +] + [[package]] name = "schist-pixel-ops" version = "0.12.0" @@ -6866,6 +7047,16 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde_bytes" +version = "0.11.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" +dependencies = [ + "serde", + "serde_core", +] + [[package]] name = "serde_core" version = "1.0.229" @@ -6974,6 +7165,17 @@ dependencies = [ "serde", ] +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha1_smol" version = "1.0.1" @@ -7095,6 +7297,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "smallstr" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "862077b1e764f04c251fe82a2ef562fd78d7cadaeb072ca7c2bcaf7217b1ff3b" +dependencies = [ + "smallvec", +] + [[package]] name = "smallvec" version = "1.15.2" @@ -7754,6 +7965,22 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + [[package]] name = "tokio-util" version = "0.7.19" @@ -8184,6 +8411,25 @@ dependencies = [ "core_maths", ] +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.5", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.20", + "utf-8", +] + [[package]] name = "twox-hash" version = "2.1.3" @@ -8342,7 +8588,7 @@ dependencies = [ "serde_json", "ureq-proto", "utf8-zero", - "webpki-roots", + "webpki-roots 1.0.9", ] [[package]] @@ -8608,6 +8854,45 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-bindgen-test" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "895a2607575412a4eda1df892084a375ea10dfeadc4d7d2ab87b854e4ddc7ba1" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288cb0ebe215033bf949ae1fd046726daa4c32a157f24b9dc6ac387a52aa759" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ff1c1b360982e93b6d8ea9c04836f71dba0817a16f91e229cf3a51bdd9d987" + [[package]] name = "wasm-encoder" version = "0.252.0" @@ -8955,6 +9240,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.9", +] + [[package]] name = "webpki-roots" version = "1.0.9" @@ -10020,6 +10314,24 @@ dependencies = [ "synstructure", ] +[[package]] +name = "yrs" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f904a99678a852d7cbc6958c94087f739c10cfb19642635951219c525a5fdb89" +dependencies = [ + "arc-swap", + "async-lock", + "async-trait", + "dashmap", + "fastrand 2.5.0", + "serde", + "serde_json", + "smallstr", + "smallvec", + "thiserror 2.0.20", +] + [[package]] name = "zbus" version = "5.19.0" diff --git a/Cargo.toml b/Cargo.toml index 1f18d86f..fd45074d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,8 @@ exclude = ["examples/plugins/sepia-filter", "examples/plugins/pgm-codec"] members = [ "crates/app", "crates/core", + "crates/document", + "crates/people-worker", "crates/layer-fx", "crates/neural", "crates/psd-descriptor", @@ -29,6 +31,7 @@ members = [ "crates/mcp", "crates/gallery", "crates/ui", + "crates/cloud", "plugins/codecs-common", "plugins/tools-basic", "plugins/tools-paint", @@ -52,6 +55,7 @@ repository = "https://github.com/Infrawrench/schist" [workspace.dependencies] # internal schist-core = { path = "crates/core" } +schist-document = { path = "crates/document" } schist-plugin-api = { path = "crates/plugin-api" } schist-pixel-ops = { path = "crates/pixel-ops" } schist-compositor = { path = "crates/compositor" } diff --git a/Makefile b/Makefile index 8b0be677..f5c3819f 100644 --- a/Makefile +++ b/Makefile @@ -228,4 +228,47 @@ check-gpu-fx: $(CARGO) test -p schist-compositor-gpu --test fx_parity --test fx_wiring check-readme: check-text check-gpu-fx $(CARGO) clippy -p schist-core -p schist-text-engine -p schist-tools-type -p schist-codec-affinity -p schist-fx -p schist-compositor-gpu --all-targets -- -D warnings +# Native cloud client and editor integration checks. +.PHONY: check-cloud +check-cloud: + $(CARGO) test -p schist-cloud + $(CARGO) test -p schist-app cloud_lifecycle_tests $(CARGO) check -p schist-app + +# Requires wasm-bindgen-test-runner and a browser WebDriver (e.g. CHROMEDRIVER). +.PHONY: check-cloud-wasm +check-cloud-wasm: + $(CARGO) check -p schist-app --target wasm32-unknown-unknown + CARGO_TARGET_WASM32_UNKNOWN_UNKNOWN_RUNNER=wasm-bindgen-test-runner $(CARGO) test -p schist-cloud -p schist-document --target wasm32-unknown-unknown --lib + +# Shared desktop/cloud document engine; no GPUI or network client. +.PHONY: document-worker check-document format-document +document-worker: + $(CARGO) build $(PROFILE_FLAG) -p schist-document --bin schist-document-worker +check-document: + $(CARGO) test -p schist-document +format-document: + $(CARGO) fmt -p schist-document -p schist-cloud -p schist-codecs-common + +# The cloud adapter uses the same face detector, recogniser and crop as desktop. +.PHONY: people-worker check-people +people-worker: + $(CARGO) build -p schist-people-worker $(if $(filter release,$(PROFILE)),--release,) +check-people: + $(CARGO) test -p schist-gallery people + $(CARGO) test -p schist-neural faces + $(CARGO) check -p schist-people-worker + +.PHONY: format-cloud +format-cloud: + $(CARGO) fmt -p schist-cloud -p schist-app -p schist-document -p schist-people-worker + +.PHONY: check-gallery check-cloud-browser +check-gallery: + $(CARGO) test -p schist-app +check-cloud-browser: + $(CARGO) check -p schist-app --target wasm32-unknown-unknown + +.PHONY: lint-cloud +lint-cloud: + $(CARGO) clippy -p schist-cloud -p schist-app -p schist-document -p schist-people-worker --all-targets -- -D warnings diff --git a/README.md b/README.md index cdaa9d7d..97950e2c 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,11 @@ file, never its output. ## What it does +**Schist Cloud.** Sign in from the welcome screen or File menu, with +`schist.app` as the default provider. Live remote folders, searchable buckets, +filters, uploads and collaborative editing share a MessagePack WebSocket. +See [docs/cloud.md](docs/cloud.md) for setup and the provider contract. + **Documents.** PSD and PSB read *and* write — layers, nested groups, masks, all 27 blend modes, adjustment layers, layer effects, vector shapes, 8/16/32-bit, RGB, greyscale, CMYK, Lab and Indexed, RLE and zip-compressed diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index de6ca384..30d60c12 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -9,6 +9,11 @@ name = "schist" path = "src/main.rs" [dependencies] +# Deflate for the gallery's ZIPs and the cloud's gzip batch uploads; +# already in the tree behind the PNG codec, so this adds no download. +flate2 = "1" +schist-cloud = { path = "../cloud" } +web-time = "1" gpui.workspace = true anyhow.workspace = true log.workspace = true @@ -76,11 +81,10 @@ async-trait.workspace = true # Gallery thumbnails: one windowless image of a file. Native-only with the # rest of the gallery — a browser tab has no folders to browse. schist-preview.workspace = true -# The gallery's "save bucket as ZIP" writes the archive by hand; these -# are its checksums and its deflate. flate2 is already in the tree -# behind the PNG codec, so this adds no download. +# The gallery's "save bucket as ZIP" writes the archive by hand; this +# is its checksum. Its deflate, flate2, sits in the common dependencies +# since cloud batch uploads gzip on every target. crc32fast.workspace = true -flate2 = "1" # The daily ping: its dedup ID, and the CPU model and RAM it reports. rand = "0.10" sysinfo = { version = "0.31", default-features = false, features = ["system"] } diff --git a/crates/app/assets/icons/loading.svg b/crates/app/assets/icons/loading.svg new file mode 100644 index 00000000..5e84562c --- /dev/null +++ b/crates/app/assets/icons/loading.svg @@ -0,0 +1 @@ + diff --git a/crates/app/src/actions.rs b/crates/app/src/actions.rs index a9efa259..0d2cb1d0 100644 --- a/crates/app/src/actions.rs +++ b/crates/app/src/actions.rs @@ -102,6 +102,11 @@ actions!( /// `panels` because [`RunAppItem`] carries one. #[derive(Clone, Copy, PartialEq, Debug)] pub enum AppItem { + CloudSignIn, + CloudGenerate, + CloudBrowse, + CloudSignOut, + CloudUpload, New, Open, Close, diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 86fc6b62..64cc4dcf 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -29,6 +29,7 @@ macro_rules! icons { } icons!( + "loading", "character", "type-align-left", "type-align-center", diff --git a/crates/app/src/dialogs/batch.rs b/crates/app/src/dialogs/batch.rs index 1ac24cf2..d9cb733a 100644 --- a/crates/app/src/dialogs/batch.rs +++ b/crates/app/src/dialogs/batch.rs @@ -311,11 +311,20 @@ pub(super) fn batch_dialog( } // Output. - let targets: Vec<(SharedString, BatchTarget)> = - [BatchTarget::Edit, BatchTarget::Beside, BatchTarget::Folder] - .into_iter() - .map(|t| (SharedString::from(t.label()), t)) - .collect(); + // Copies fetched from Schist Cloud live in a scratch folder, so the + // only place results can usefully go is a folder of the user's own. + let from_cloud = photos + .first() + .is_some_and(|p| p.starts_with(crate::workspace::cloud::batch_dir())); + let choices: &[BatchTarget] = if from_cloud { + &[BatchTarget::Folder] + } else { + &[BatchTarget::Edit, BatchTarget::Beside, BatchTarget::Folder] + }; + let targets: Vec<(SharedString, BatchTarget)> = choices + .iter() + .map(|&t| (SharedString::from(t.label()), t)) + .collect(); body = body.child(section("Output")).child(ui::field_row( "Save as", ui::dropdown( diff --git a/crates/app/src/dialogs/close.rs b/crates/app/src/dialogs/close.rs index d2e5f195..a34acbc2 100644 --- a/crates/app/src/dialogs/close.rs +++ b/crates/app/src/dialogs/close.rs @@ -57,6 +57,14 @@ pub(super) fn confirm_close_tab( ws.close_tab_after_save(); ws.save_current(window, cx); if ws.has_pending_save() { + if ws + .doc + .as_ref() + .is_some_and(|doc| ws.cloud.docs.contains_key(&doc.id)) + { + // The cloud acknowledgement resumes the pending quit. + return; + } // Still waiting on a file prompt. Do not hold a // quit open across it; the tab closes when the // save lands. diff --git a/crates/app/src/dialogs/mod.rs b/crates/app/src/dialogs/mod.rs index 51febc1f..85831d96 100644 --- a/crates/app/src/dialogs/mod.rs +++ b/crates/app/src/dialogs/mod.rs @@ -100,6 +100,8 @@ pub fn render(ws: &mut Workspace, cx: &mut Context) -> Option crate::workspace::cloud_generation::dialog(ws, cx), + Modal::Cloud { kind, fields } => crate::workspace::cloud_view::dialog(ws, kind, fields, cx), Modal::ImageSize { width, height, @@ -223,10 +225,6 @@ pub fn render(ws: &mut Workspace, cx: &mut Context) -> Option crate::workspace::search_models_dialog(cx).into_any_element(), #[cfg(not(target_arch = "wasm32"))] - Modal::PeopleModels => crate::workspace::people_models_dialog(cx).into_any_element(), - #[cfg(target_arch = "wasm32")] - Modal::PeopleModels => return None, - #[cfg(not(target_arch = "wasm32"))] Modal::PersonName { index, name } => { crate::workspace::person_name_dialog(ws, index, name, cx).into_any_element() } @@ -264,8 +262,11 @@ pub fn render(ws: &mut Workspace, cx: &mut Context) -> Option crate::workspace::bucket_name_dialog(ws, name, query, photos.len(), editing, cx) - .into_any_element(), + cloud, + } => { + crate::workspace::bucket_name_dialog(ws, name, query, photos.len(), editing, cloud, cx) + .into_any_element() + } #[cfg(target_arch = "wasm32")] Modal::BucketName { .. } => return None, m @ Modal::NewDocument { .. } => new_document_dialog(&state, m, cx).into_any_element(), diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 03aa6297..57bb52b0 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -49,49 +49,13 @@ use gpui::{ px, size, App, AppContext as _, Application, AsyncApp, Bounds, TitlebarOptions, WindowBounds, WindowHandle, WindowOptions, }; -use schist_plugin_api::{CodecPlugin, PluginManifest, PluginRegistry}; +use schist_plugin_api::{PluginManifest, PluginRegistry}; use std::cell::RefCell; use std::path::PathBuf; use std::rc::Rc; use workspace::Workspace; -/// PSD/PSB import and export via `schist-codec-psd`. -struct PsdCodec; - -impl CodecPlugin for PsdCodec { - fn id(&self) -> &'static str { - "codec.psd" - } - fn name(&self) -> &'static str { - "Photoshop PSD" - } - fn extensions(&self) -> &'static [&'static str] { - &["psd", "psb"] - } - fn probe(&self, bytes: &[u8]) -> bool { - schist_codec_psd::is_psd(bytes) - } - fn import(&self, bytes: &[u8]) -> anyhow::Result { - Ok(schist_codec_psd::read_psd(bytes)?) - } - fn can_export(&self) -> bool { - true - } - fn export(&self, doc: &schist_core::Document) -> anyhow::Result> { - Ok(schist_codec_psd::write_psd(doc)?) - } -} - -struct PsdPlugin; - -impl PluginManifest for PsdPlugin { - fn id(&self) -> &'static str { - "schist.codec-psd" - } - fn register(&self, registry: &mut PluginRegistry) { - registry.register_codec(Box::new(PsdCodec)); - } -} +pub use schist_codecs_common::{PsdCodec, PsdPlugin}; /// Whether an opt-in diagnostic is on: the preference, or the environment /// variable that overrides it for one run. @@ -232,6 +196,21 @@ fn path_from_url(url: &str) -> Option { } fn main() { + #[cfg(not(target_arch = "wasm32"))] + if let Some(callback) = std::env::args() + .skip(1) + .find(|arg| arg.starts_with("schist://")) + { + let dir = schist_gallery::state_dir() + .unwrap_or_else(std::env::temp_dir) + .join("schist/cloud"); + if let Err(error) = schist_cloud::auth::forward_callback(&callback, &dir) { + eprintln!("Cloud sign-in callback failed: {error}"); + std::process::exit(1); + } + return; + } + // `schist --mcp-bridge ` is not a GUI launch at all: it is the // stdio pump an agent harness spawns as its "MCP server", forwarding // into the running app's loopback endpoint. Handled before anything @@ -308,6 +287,15 @@ fn main() { app.on_open_urls({ let requests = requests.clone(); move |urls| { + #[cfg(not(target_arch = "wasm32"))] + for callback in urls.iter().filter(|url| url.starts_with("schist://")) { + let dir = schist_gallery::state_dir() + .unwrap_or_else(std::env::temp_dir) + .join("schist/cloud"); + if let Err(error) = schist_cloud::auth::forward_callback(callback, &dir) { + log::error!("Cloud callback failed: {error}"); + } + } let paths: Vec = urls.iter().filter_map(|u| path_from_url(u)).collect(); if paths.is_empty() { return; @@ -379,7 +367,8 @@ fn main() { ws.recover_all(recoveries, cx); } if let Some(path) = std::env::args().nth(1) { - ws.load_file(path.into(), cx); + let path = path_from_url(&path).unwrap_or_else(|| path.into()); + ws.load_file(path, cx); } else if ws.tab_count() == 0 { // Picasa boot: a launch with nothing to // open lands in the gallery, empty or diff --git a/crates/app/src/native_menu.rs b/crates/app/src/native_menu.rs index 565d7bad..10e8040f 100644 --- a/crates/app/src/native_menu.rs +++ b/crates/app/src/native_menu.rs @@ -62,6 +62,12 @@ fn signature(ws: &Workspace) -> String { out.push('\u{1f}'); out.push_str(&recent.to_string_lossy()); } + #[cfg(not(target_arch = "wasm32"))] + out.push_str(if ws.cloud.account.is_some() { + "cloud-in" + } else { + "cloud-out" + }); out } diff --git a/crates/app/src/panels/menu_bar.rs b/crates/app/src/panels/menu_bar.rs index d2cb21eb..6a2f7941 100644 --- a/crates/app/src/panels/menu_bar.rs +++ b/crates/app/src/panels/menu_bar.rs @@ -25,6 +25,21 @@ pub(crate) fn run_app_item( cx: &mut Context, ) { match item { + AppItem::CloudGenerate => { + ws.cloud_generate_open(cx); + } + AppItem::CloudSignIn => { + ws.cloud_sign_in(cx); + } + AppItem::CloudBrowse => { + ws.cloud_browse(schist_cloud::Scope::Library, cx); + } + AppItem::CloudSignOut => { + ws.cloud_sign_out(cx); + } + AppItem::CloudUpload => { + ws.cloud_upload_document(cx); + } AppItem::New => ws.open_new_file_picker(cx), AppItem::Open => crate::keymap::open_file_dialog(ws, window, cx), AppItem::Close => ws.request_close_tab(ws.active_tab(), cx), diff --git a/crates/app/src/panels/menus.rs b/crates/app/src/panels/menus.rs index 2e5bdd95..e377d1ca 100644 --- a/crates/app/src/panels/menus.rs +++ b/crates/app/src/panels/menus.rs @@ -53,6 +53,7 @@ pub(crate) fn menus(ws: &Workspace) -> Vec<(&'static str, Vec)> { vec![ App("New", New, Some("cmd-n")), App("Open…", Open, Some("cmd-o")), + Sub("Schist Cloud", cloud_entries(ws)), App("Browse Gallery…", OpenGallery, Some("cmd-shift-g")), App("Close", Close, Some("cmd-w")), App("Save", Save, Some("cmd-s")), @@ -319,6 +320,7 @@ fn gallery_menus(ws: &Workspace) -> Vec<(&'static str, Vec)> { let mut file = vec![ App("New", New, Some("cmd-n")), App("Open…", Open, Some("cmd-o")), + Sub("Schist Cloud", cloud_entries(ws)), ]; let recents = recent_entries(ws); if !recents.is_empty() { @@ -656,3 +658,17 @@ pub(super) const FILTER_GROUPS: &[(&str, &[&str])] = &[ ], ), ]; +fn cloud_entries(ws: &Workspace) -> Vec { + use AppItem::*; + use MenuEntry::*; + if ws.cloud.account.is_some() { + vec![ + App("Browse Schist Cloud", CloudBrowse, None), + App("Generate images…", CloudGenerate, None), + App("Upload document to Schist Cloud…", CloudUpload, None), + App("Sign out of Schist Cloud", CloudSignOut, None), + ] + } else { + vec![App("Sign into Schist Cloud…", CloudSignIn, None)] + } +} diff --git a/crates/app/src/ui.rs b/crates/app/src/ui.rs index b5e885bd..cb7f8027 100644 --- a/crates/app/src/ui.rs +++ b/crates/app/src/ui.rs @@ -712,3 +712,182 @@ mod tests { assert_eq!(caret_left("", 0), 0); } } + +/// A one-line text box's state: the text, a caret on a char boundary, +/// a whole-line selection, and whether it is taking keystrokes. The +/// gallery search boxes (local and cloud) share it, so typing, ⌘A, +/// paste and the arrows behave identically in both. +#[derive(Clone, Debug, Default)] +pub struct LineEdit { + pub text: String, + /// Byte position, always on a char boundary — arrows move it, + /// typing inserts at it. + pub cursor: usize, + /// ⌘A selected the whole line: the next keystroke replaces it, + /// backspace clears it, ⌘C/⌘X take it — the minimal selection a + /// one-line box owes the keyboard. + pub selected: bool, + pub active: bool, +} + +/// What a keystroke did to a [`LineEdit`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum LineEditKey { + /// Not for the box; let it propagate. + Ignored, + /// The caret or selection moved; the text is unchanged. + Moved, + /// The text changed. + Changed, + /// Enter: the box gave the keyboard back. + Submitted, +} + +impl LineEdit { + /// A click lands a caret at the end, not a selection. + pub fn focus(&mut self) { + self.active = true; + self.selected = false; + self.cursor = self.text.len(); + } + + /// Empty and inactive. + pub fn clear(&mut self) { + self.text.clear(); + self.cursor = 0; + self.selected = false; + self.active = false; + } + + pub fn set_text(&mut self, text: String) { + self.cursor = text.len(); + self.text = text; + self.selected = false; + } + + fn replace_selection(&mut self) { + if self.selected { + self.text.clear(); + self.cursor = 0; + self.selected = false; + } + } + + /// A keystroke while the box is active. `cx` is for the clipboard. + pub fn key(&mut self, ev: &gpui::KeyDownEvent, cx: &mut gpui::App) -> LineEditKey { + if !self.active { + return LineEditKey::Ignored; + } + let primary = ev.keystroke.modifiers.platform || ev.keystroke.modifiers.control; + // Keep the caret on the rails whatever changed the text. + self.cursor = self.cursor.min(self.text.len()); + match ev.keystroke.key.as_str() { + "a" if primary => { + self.selected = !self.text.is_empty(); + self.cursor = self.text.len(); + LineEditKey::Moved + } + "c" if primary && self.selected => { + cx.write_to_clipboard(gpui::ClipboardItem::new_string(self.text.clone())); + LineEditKey::Moved + } + "x" if primary && self.selected => { + cx.write_to_clipboard(gpui::ClipboardItem::new_string(self.text.clone())); + self.replace_selection(); + LineEditKey::Changed + } + "v" if primary => { + let Some(pasted) = cx.read_from_clipboard().and_then(|item| item.text()) else { + return LineEditKey::Moved; + }; + // One line: a pasted paragraph flattens rather than + // breaking the box. + let pasted: String = pasted + .chars() + .map(|c| if c.is_control() { ' ' } else { c }) + .collect(); + self.replace_selection(); + let at = self.cursor; + self.text.insert_str(at, &pasted); + self.cursor = at + pasted.len(); + LineEditKey::Changed + } + "left" | "right" if primary => { + // ⌘←/⌘→: the ends of the line. + self.selected = false; + self.cursor = if ev.keystroke.key == "left" { + 0 + } else { + self.text.len() + }; + LineEditKey::Moved + } + "left" => { + self.cursor = if self.selected { + 0 + } else { + caret_left(&self.text, self.cursor) + }; + self.selected = false; + LineEditKey::Moved + } + "right" => { + self.cursor = if self.selected { + self.text.len() + } else { + caret_right(&self.text, self.cursor).min(self.text.len()) + }; + self.selected = false; + LineEditKey::Moved + } + "home" | "up" => { + self.cursor = 0; + self.selected = false; + LineEditKey::Moved + } + "end" | "down" => { + self.cursor = self.text.len(); + self.selected = false; + LineEditKey::Moved + } + "backspace" => { + if self.selected { + self.replace_selection(); + } else if self.cursor > 0 { + let from = caret_left(&self.text, self.cursor); + self.text.replace_range(from..self.cursor, ""); + self.cursor = from; + } + LineEditKey::Changed + } + "delete" => { + if self.selected { + self.replace_selection(); + } else if self.cursor < self.text.len() { + let to = caret_right(&self.text, self.cursor); + self.text.replace_range(self.cursor..to, ""); + } + LineEditKey::Changed + } + "enter" => { + self.active = false; + self.selected = false; + LineEditKey::Submitted + } + _ => { + let Some(text) = ev.keystroke.key_char.as_deref() else { + return LineEditKey::Ignored; + }; + if text.chars().any(char::is_control) { + return LineEditKey::Ignored; + } + // Typing over a selection replaces it, as anywhere. + self.replace_selection(); + let at = self.cursor; + self.text.insert_str(at, text); + self.cursor = at + text.len(); + LineEditKey::Changed + } + } + } +} diff --git a/crates/app/src/web/mod.rs b/crates/app/src/web/mod.rs index 458fd0c2..bce743f9 100644 --- a/crates/app/src/web/mod.rs +++ b/crates/app/src/web/mod.rs @@ -349,3 +349,133 @@ pub fn local_set(key: &str, value: &str) { let _ = storage.set_item(key, value); } } + +/// Browser file/folder imports retain relative paths for cloud upload tickets. +pub fn pick_cloud_files( + directory: bool, +) -> impl std::future::Future>>> { + let setup = (|| -> anyhow::Result<_> { + let document = web_sys::window() + .and_then(|w| w.document()) + .ok_or_else(|| anyhow::anyhow!("No browser document"))?; + let input: web_sys::HtmlInputElement = document + .create_element("input") + .map_err(|_| anyhow::anyhow!("File picker unavailable"))? + .unchecked_into(); + input.set_type("file"); + input.set_multiple(true); + if directory { + input + .set_attribute("webkitdirectory", "") + .map_err(|_| anyhow::anyhow!("Folder picker unavailable"))?; + } + let _ = input.style().set_property("display", "none"); + if let Some(body) = document.body() { + let _ = body.append_child(&input); + } + let (tx, rx) = futures::channel::oneshot::channel(); + let tx = std::rc::Rc::new(std::cell::RefCell::new(Some(tx))); + let selected = input.clone(); + let sender = tx.clone(); + let change = Closure::::new(move |_| { + let files = selected.files(); + let sender = sender.clone(); + wasm_bindgen_futures::spawn_local(async move { + let result = async { + let Some(files) = files else { + return Ok(None); + }; + let total: f64 = (0..files.length()) + .filter_map(|i| files.item(i)) + .map(|f| f.size()) + .sum(); + anyhow::ensure!( + total <= 512.0 * 1024.0 * 1024.0, + "Choose at most 512 MiB of files per upload" + ); + let mut paths = Vec::new(); + for i in 0..files.length() { + let file = files + .item(i) + .ok_or_else(|| anyhow::anyhow!("File selection changed"))?; + let name = if directory { + js_sys::Reflect::get(&file, &"webkitRelativePath".into()) + .ok() + .and_then(|p| p.as_string()) + .filter(|p| !p.is_empty()) + .unwrap_or_else(|| file.name()) + } else { + file.name() + }; + anyhow::ensure!( + !name.contains(['\\', '\0', ':']) + && name + .split('/') + .all(|s| !s.is_empty() && s != "." && s != ".."), + "Invalid import path" + ); + let buffer = wasm_bindgen_futures::JsFuture::from(file.array_buffer()) + .await + .map_err(|_| anyhow::anyhow!("Could not read selected file"))?; + paths.push(files::store( + &name, + js_sys::Uint8Array::new(&buffer).to_vec(), + )); + } + Ok(Some(paths)) + } + .await; + if let Some(sender) = sender.borrow_mut().take() { + let _ = sender.send(result); + } + }); + }); + let cancel = Closure::::new(move |_| { + if let Some(tx) = tx.borrow_mut().take() { + let _ = tx.send(Ok(None)); + } + }); + input.set_onchange(Some(change.as_ref().unchecked_ref())); + input + .add_event_listener_with_callback("cancel", cancel.as_ref().unchecked_ref()) + .map_err(|_| anyhow::anyhow!("File picker unavailable"))?; + struct Picker { + input: web_sys::HtmlInputElement, + _change: Closure, + cancel: Closure, + } + impl Drop for Picker { + fn drop(&mut self) { + self.input.set_onchange(None); + let _ = self.input.remove_event_listener_with_callback( + "cancel", + self.cancel.as_ref().unchecked_ref(), + ); + self.input.remove(); + } + } + input.click(); + Ok(( + Picker { + input, + _change: change, + cancel, + }, + rx, + )) + })(); + async move { + let (_picker, rx) = setup?; + rx.await + .map_err(|_| anyhow::anyhow!("File picker closed"))? + } +} +pub fn cloud_relative_path(path: &Path) -> Option { + let relative: PathBuf = path + .strip_prefix("/web/open") + .ok()? + .components() + .skip(1) + .collect(); + (relative.components().count() > 1).then(|| relative.to_string_lossy().into_owned()) +} diff --git a/crates/app/src/workspace/cloud.rs b/crates/app/src/workspace/cloud.rs new file mode 100644 index 00000000..c083795d --- /dev/null +++ b/crates/app/src/workspace/cloud.rs @@ -0,0 +1,3524 @@ +//! Cloud account, live queries and the editor/provider binding. The socket lives +//! in schist-cloud; this module translates its events into GPUI state changes. +use super::gallery_chrome::GridScroll; +#[cfg(target_arch = "wasm32")] +use super::gallery_chrome::GroupBy; +use super::*; +use crate::ui::LineEdit; +use anyhow::{anyhow, Result}; +use remote::transfer::DownloadedAsset; +use schist_cloud::{ + self as remote, + protocol::{bytes, map, parse, value}, + Account, Asset, AssetQuery, Bucket, CatalogueQuery, Client, Event, Filters, Folder, Scope, + Value, WatchQuery, +}; +use schist_core::DocumentId; +use std::{ + collections::{HashMap, HashSet, VecDeque}, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc, + }, + time::Duration, +}; + +#[cfg(not(target_arch = "wasm32"))] +const CREDENTIAL_KEY: &str = "https://schist.app/schist-cloud"; +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn state_dir() -> PathBuf { + schist_gallery::state_dir() + .unwrap_or_else(std::env::temp_dir) + .join("schist/cloud") +} + +pub(crate) struct RemoteDocument { + pub asset: Asset, + pub shared: remote::document::SharedDocument, + pub joined: bool, + pub detached: bool, + pub vector: Vec, + pub sending: bool, + pub changed: bool, + pub generation: u64, + pub saved: u64, + pub render: bool, +} +enum Pending { + Capabilities, + Join(DocumentId), + Update { + doc: DocumentId, + vector: Vec, + generation: u64, + }, + Mutation, +} +impl Pending { + fn belongs_to(&self, id: DocumentId) -> bool { + matches!(self, Self::Join(doc) | Self::Update { doc, .. } if *doc == id) + } +} +impl RemoteDocument { + fn detach(&mut self) { + self.detached = true; + self.joined = false; + self.sending = false; + self.render = false; + } +} +#[cfg(not(target_arch = "wasm32"))] +enum RecoveryTask { + Write { + epoch: u64, + files: Vec<(PathBuf, Vec)>, + }, + Remove(PathBuf), +} +enum Job { + Thumbnail { + epoch: u64, + id: String, + revision: u64, + image: Option>, + }, + #[cfg(not(target_arch = "wasm32"))] + Browser { + epoch: u64, + url: String, + }, + SignedIn { + epoch: u64, + account: Account, + }, + Error { + epoch: u64, + error: String, + }, + Opened { + epoch: u64, + asset: Asset, + download: DownloadedAsset, + }, + Downloaded { + epoch: u64, + name: String, + download: DownloadedAsset, + }, + Uploaded { + epoch: u64, + doc: DocumentId, + asset: Asset, + }, + Done { + epoch: u64, + message: String, + }, + /// A long transfer's progress, for the tray's bar. + Progress { + epoch: u64, + done: u64, + total: u64, + label: String, + }, + /// A bucket's originals landed in a scratch folder for the batch + /// dialog. + #[cfg(not(target_arch = "wasm32"))] + Batch { + epoch: u64, + paths: Vec, + }, + /// The world map's located assets for one query. + #[cfg(not(target_arch = "wasm32"))] + MapAssets { + epoch: u64, + key: (AssetQuery, u64), + result: std::result::Result, String>, + }, +} +/// What the cloud gallery's right-click menu is about. +#[derive(Clone, Debug)] +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub(crate) enum CloudContext { + Photo(String), + Folder(String), + Bucket(String), + /// A world-map marker's photos. + Cluster(Vec), + /// The ☁ Schist Cloud root row. + Library, + /// A named person in the cloud's PEOPLE list. + Person(String), +} +/// Assets per page. A page's thumbnails stay decoded while it shows, +/// so this bounds texture memory as much as it bounds the query. +pub(crate) const PAGE_SIZE: u64 = 200; +/// Concurrent thumbnail fetches, and how many decoded thumbnails stay +/// in memory (~256 KB each) before the cache shrinks to the page. +const THUMBNAIL_WORKERS: usize = 8; +const THUMBNAIL_CACHE: usize = 600; +pub(crate) struct CloudState { + pub generation: super::cloud_generation::GenerationState, + pub account: Option, + pub client: Option, + pub connected: bool, + pub capabilities: Option, + pub capabilities_ready: bool, + pub download_target: Option, + pub show: bool, + pub message: String, + pub thumbnails: HashMap)>, + thumbnail_jobs: HashSet<(String, u64)>, + thumbnail_active: usize, + pub folders: Vec, + pub buckets: Vec, + pub assets: Vec, + pub total: u64, + pub query: AssetQuery, + /// The selected assets, in the order they were picked; the last is + /// the lead — what arrows move and Enter opens. + pub selected: Vec, + /// Where a Shift-click range extends from. + pub select_anchor: Option, + /// The search box in the top strip; its text becomes the query + /// after a short pause in typing. + pub search: LineEdit, + pub search_seq: u64, + /// The grid's scroll and viewport bookkeeping. + pub grid: GridScroll, + /// The gallery's right-click menu: where, and on what. + pub context: Option<(Point, CloudContext)>, + /// Whether the current asset watch has delivered its first snapshot. + pub loaded: bool, + /// A failed watch is unavailable, not an empty library or an ongoing load. + pub load_error: Option, + /// "Select all" asked before the page arrived: select it on landing. + pub select_all_pending: bool, + /// The photos of the world-map marker last clicked, for its strip. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + pub map_photos: Vec, + /// Every located photo in the scope on show, for the world map — + /// the whole scope, not one page — and the query plus change count + /// it answers, so it refetches only when either moves. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + pub map_assets: Vec, + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + pub map_key: Option<(AssetQuery, u64)>, + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + pub map_loading: bool, + /// Marker previews asked for this frame: their thumbnails load + /// alongside the page's. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + pub map_wanted: HashSet, + /// Bumped on every asset snapshot, so caches keyed by it refresh. + pub changes: u64, + /// A transfer under way: done, total, and what it is doing — the + /// tray draws a bar from it in either room. + pub progress: Option<(u64, u64, String)>, + /// Thumbnails whose fetch or decode failed at the current revision. + pub thumbnail_failed: HashSet, + /// The browser has no local gallery to keep these in. + #[cfg(target_arch = "wasm32")] + pub thumb_px: f32, + #[cfg(target_arch = "wasm32")] + pub group_by: GroupBy, + pub catalogue: String, + pub folders_offset: u64, + pub buckets_offset: u64, + pub folders_total: u64, + pub library_total: Option, + pub screening: remote::Screening, + pub people: Option, + pub face_bounds: Bounds, + pub face_start: Option<(f32, f32)>, + pub face_draft: Option, + pub face_drawing: bool, + pub buckets_total: u64, + pub docs: HashMap, + pending: HashMap, + pub epoch: u64, + jobs: mpsc::Receiver, + sender: mpsc::Sender, + cancel: Arc, + writes: VecDeque>, + #[cfg(not(target_arch = "wasm32"))] + writing: bool, + #[cfg(not(target_arch = "wasm32"))] + recovery: mpsc::Sender, + pub watching: String, + folders_watch: String, + buckets_watch: String, + pub form_target: Option<(String, u64)>, + pub form_scope: Scope, +} +impl Default for CloudState { + fn default() -> Self { + let (sender, jobs) = mpsc::channel(); + #[cfg(not(target_arch = "wasm32"))] + let recovery = { + let (recovery, tasks) = mpsc::channel(); + let errors = sender.clone(); + std::thread::spawn(move || { + while let Ok(task) = tasks.recv() { + match task { + RecoveryTask::Write { epoch, files } => { + for (path, bytes) in files { + if let Err(e) = remote::auth::private_write(&path, &bytes) { + let _ = errors.send(Job::Error { + epoch, + error: format!("Cloud recovery failed: {e}"), + }); + } + } + } + RecoveryTask::Remove(path) => { + let _ = std::fs::remove_file(path); + } + } + } + }); + recovery + }; + Self { + generation: Default::default(), + account: None, + client: None, + connected: false, + capabilities: None, + capabilities_ready: false, + download_target: None, + show: false, + message: "Not signed in".into(), + thumbnails: HashMap::new(), + thumbnail_jobs: HashSet::new(), + thumbnail_active: 0, + folders: vec![], + buckets: vec![], + assets: vec![], + total: 0, + query: AssetQuery { + limit: PAGE_SIZE, + ..Default::default() + }, + selected: Vec::new(), + select_anchor: None, + search: LineEdit::default(), + search_seq: 0, + grid: GridScroll::default(), + context: None, + loaded: false, + load_error: None, + select_all_pending: false, + map_photos: Vec::new(), + map_assets: Vec::new(), + map_key: None, + map_loading: false, + map_wanted: HashSet::new(), + changes: 0, + progress: None, + thumbnail_failed: HashSet::new(), + #[cfg(target_arch = "wasm32")] + thumb_px: 144.0, + #[cfg(target_arch = "wasm32")] + group_by: GroupBy::Date, + catalogue: String::new(), + folders_offset: 0, + buckets_offset: 0, + folders_total: 0, + library_total: None, + screening: remote::Screening::default(), + people: None, + face_bounds: Bounds::default(), + face_start: None, + face_draft: None, + face_drawing: false, + buckets_total: 0, + docs: HashMap::new(), + pending: HashMap::new(), + epoch: 0, + jobs, + sender, + cancel: Arc::new(AtomicBool::new(false)), + writes: VecDeque::new(), + #[cfg(not(target_arch = "wasm32"))] + writing: false, + #[cfg(not(target_arch = "wasm32"))] + recovery, + watching: String::new(), + folders_watch: String::new(), + buckets_watch: String::new(), + form_target: None, + form_scope: Scope::Library, + } + } +} +impl Drop for CloudState { + fn drop(&mut self) { + self.cancel.store(true, Ordering::Relaxed); + } +} +impl CloudState { + pub(crate) fn is_loading(&self) -> bool { + self.account.is_some() && !self.loaded && self.load_error.is_none() + } + fn joinable_documents(&self) -> Vec { + self.docs + .iter() + .filter(|(_, doc)| !doc.detached) + .map(|(id, _)| *id) + .collect() + } + fn disconnect(&mut self) { + self.connected = false; + self.capabilities_ready = false; + for document in self.docs.values_mut() { + document.joined = false; + document.sending = false; + } + self.pending.clear(); + } + fn detach_document(&mut self, asset: &str) -> Option { + let id = self + .docs + .iter() + .find(|(_, document)| document.asset.id == asset) + .map(|(id, _)| *id)?; + self.docs.get_mut(&id)?.detach(); + self.pending.retain(|_, pending| !pending.belongs_to(id)); + Some(id) + } + fn apply_document_update(&mut self, asset: &str, update: &[u8]) -> Result<()> { + if let Some(document) = self + .docs + .values_mut() + .find(|doc| doc.asset.id == asset && !doc.detached) + { + document.shared.apply(update)?; + document.render = true; + } + Ok(()) + } + fn reopen_document(&mut self, id: DocumentId) -> bool { + let Some(document) = self.docs.get_mut(&id) else { + return false; + }; + std::mem::replace(&mut document.detached, false) + } +} +impl Workspace { + pub(crate) fn cloud_start(&mut self, cx: &mut Context) { + #[cfg(not(target_arch = "wasm32"))] + { + let read = cx.read_credentials(CREDENTIAL_KEY); + let epoch = self.cloud.epoch; + cx.spawn(async move |this, cx| { + let result = read.await; + let _ = this.update(cx, |ws, cx| { + if ws.cloud.epoch != epoch { + return; + } + match result { + Ok(Some((_, data))) => match serde_json::from_slice::(&data) { + Ok(account) => ws.cloud_connect(account, cx), + Err(e) => ws.cloud_error(format!("Stored cloud login is invalid: {e}")), + }, + Ok(None) => {} + Err(e) => ws.cloud_error(format!("Could not read cloud login: {e}")), + } + }); + }) + .detach(); + } + cx.spawn(async move |this, cx| loop { + cx.background_executor() + .timer(Duration::from_millis(75)) + .await; + if this.update(cx, |ws, cx| ws.cloud_tick(cx)).is_err() { + break; + } + }) + .detach(); + } + fn cloud_error(&mut self, error: impl Into) { + let error = error.into(); + self.status = error.clone().into(); + self.cloud.message = error; + } + pub(crate) fn cloud_sign_in(&mut self, cx: &mut Context) { + #[cfg(target_arch = "wasm32")] + { + self.cloud_login("https://schist.app".into(), cx); + } + #[cfg(not(target_arch = "wasm32"))] + { + self.open_modal( + Modal::Cloud { + kind: "sign-in", + fields: vec![( + "cloud-domain", + "Domain".into(), + remote::DEFAULT_DOMAIN.into(), + )], + }, + cx, + ); + self.focus_field("cloud-domain", remote::DEFAULT_DOMAIN); + } + } + fn cloud_login(&mut self, domain: String, cx: &mut Context) { + self.cloud.epoch += 1; + let epoch = self.cloud.epoch; + self.cloud.cancel.store(true, Ordering::Relaxed); + self.cloud.cancel = Arc::new(AtomicBool::new(false)); + let cancel = self.cloud.cancel.clone(); + let sender = self.cloud.sender.clone(); + self.cloud.message = "Opening sign-in in your browser…".into(); + #[cfg(not(target_arch = "wasm32"))] + std::thread::spawn(move || { + let result = (|| -> Result<()> { + let login = remote::auth::Login::discover(&domain, &state_dir())?; + let _ = sender.send(Job::Browser { + epoch, + url: login.browser_url.clone(), + }); + while !cancel.load(Ordering::Relaxed) { + if let Some(callback) = login.poll()? { + match login.exchange(&callback) { + Ok(account) => { + let _ = sender.send(Job::SignedIn { epoch, account }); + return Ok(()); + } + Err(e) => { + let _ = sender.send(Job::Error { + epoch, + error: e.to_string(), + }); + } + } + } + std::thread::sleep(Duration::from_millis(100)); + } + Ok(()) + })(); + if let Err(e) = result { + let _ = sender.send(Job::Error { + epoch, + error: format!("Cloud sign-in failed: {e}"), + }); + } + }); + #[cfg(target_arch = "wasm32")] + { + let result = remote::auth::domain(&domain).and_then(|_| remote::auth::Login::open()); + match result { + Ok(login) => remote::runtime::spawn(async move { + let job = match login.finish(&cancel).await { + Ok(account) => Job::SignedIn { epoch, account }, + Err(e) => Job::Error { + epoch, + error: e.to_string(), + }, + }; + let _ = sender.send(job); + }), + Err(e) => self.cloud_error(e.to_string()), + } + } + cx.notify(); + } + fn cloud_connect(&mut self, account: Account, cx: &mut Context) { + self.cloud.library_total = None; + self.cloud.client = Some(Client::start(account.clone())); + self.cloud.account = Some(account.clone()); + self.cloud.writes.push_back(Some(account)); + self.cloud.message = "Connecting to Schist Cloud…".into(); + self.cloud.pending.clear(); + self.cloud_refresh_catalogue(); + // Connect quietly: the cloud's rows appear in the sidebar, but + // whatever screen is up stays up — a stored login must not pull + // an open image out from under its editor. + self.cloud.query.scope = Scope::Library; + self.cloud.query.offset = 0; + self.cloud_watch_assets(false); + cx.notify(); + } + pub(crate) fn cloud_sign_out(&mut self, cx: &mut Context) { + self.cloud_capture_edit(); + self.cloud_checkpoint(); + self.cloud.epoch += 1; + self.cloud.cancel.store(true, Ordering::Relaxed); + self.cloud.generation.cancel.store(true, Ordering::Relaxed); + self.cloud.generation = Default::default(); + let account = self.cloud.account.take(); + self.cloud.client = None; + self.cloud.connected = false; + self.cloud.capabilities = None; + self.cloud.capabilities_ready = false; + self.cloud.download_target = None; + self.cloud.show = false; + self.cloud.pending.clear(); + self.cloud.docs.clear(); + self.cloud.assets.clear(); + self.cloud.thumbnails.clear(); + self.cloud.library_total = None; + self.cloud.thumbnail_jobs.clear(); + self.cloud.thumbnail_active = 0; + self.cloud.thumbnail_failed.clear(); + self.cloud.selected.clear(); + self.cloud.select_anchor = None; + self.cloud.search.clear(); + self.cloud.context = None; + self.cloud.folders.clear(); + self.cloud.buckets.clear(); + self.cloud.writes.push_back(None); + self.cloud.message = "Signed out".into(); + if let Some(account) = account { + let sender = self.cloud.sender.clone(); + let epoch = self.cloud.epoch; + remote::runtime::spawn(async move { + if let Err(e) = remote::auth::logout_async(&account).await { + let _ = sender.send(Job::Error { + epoch, + error: format!("Signed out locally; server logout failed: {e}"), + }); + } + }); + } + cx.notify(); + } + pub(crate) fn cloud_set_visible(&mut self, visible: bool) { + self.cloud.show = visible; + #[cfg(not(target_arch = "wasm32"))] + { + self.library.open = visible; + } + } + pub(crate) fn cloud_browse(&mut self, scope: Scope, cx: &mut Context) { + if self.cloud.account.is_none() { + self.cloud_sign_in(cx); + return; + } + self.cloud.show = true; + self.cloud_set_visible(true); + self.cloud.query.scope = scope; + self.cloud.query.offset = 0; + self.cloud.selected.clear(); + self.cloud.select_anchor = None; + self.cloud.context = None; + self.cloud_watch_assets(false); + cx.notify(); + } + /// (Re)subscribe to the assets the query names. `keep` leaves the + /// current page on screen until the new one lands — what a search + /// refinement wants; a change of scope starts from a blank grid. + pub(crate) fn cloud_watch_assets(&mut self, keep: bool) { + self.cloud.query.sort = self.cloud_sort(); + self.cloud.query.limit = PAGE_SIZE; + self.cloud.loaded = false; + self.cloud.load_error = None; + if !keep { + self.cloud.assets.clear(); + self.cloud.total = 0; + self.cloud.grid.handle.set_offset(point(px(0.0), px(0.0))); + } + if let Some(c) = &self.cloud.client { + if !self.cloud.watching.is_empty() { + c.handle.unwatch(&self.cloud.watching); + } + self.cloud.watching = remote::Uuid::new_v4().to_string(); + c.handle.watch( + &self.cloud.watching, + WatchQuery::Assets { + query: Box::new(self.cloud.query.clone()), + }, + ); + } + } + pub(crate) fn cloud_refresh_catalogue(&mut self) { + if let Some(c) = &self.cloud.client { + c.handle.unwatch(&self.cloud.folders_watch); + c.handle.unwatch(&self.cloud.buckets_watch); + self.cloud.folders_watch = remote::Uuid::new_v4().to_string(); + self.cloud.buckets_watch = remote::Uuid::new_v4().to_string(); + c.handle.watch( + &self.cloud.folders_watch, + WatchQuery::Folders { + query: CatalogueQuery { + text: self.cloud.catalogue.clone(), + offset: self.cloud.folders_offset, + limit: 500, + }, + }, + ); + c.handle.watch( + &self.cloud.buckets_watch, + WatchQuery::Buckets { + query: CatalogueQuery { + text: self.cloud.catalogue.clone(), + offset: self.cloud.buckets_offset, + limit: 500, + }, + }, + ); + } + } + fn cloud_load_thumbnails(&mut self) { + if !self.cloud.show { + return; + } + // A small worker pool bounds the network; decoded thumbnails + // stay for a few pages, so paging back is instant, and only an + // overfull cache falls back to the page on show. + // The page's photos, then whatever the world map's markers asked + // for this frame. + let wanted: Vec<(String, u64, Option)> = self + .cloud + .assets + .iter() + .chain( + self.cloud + .map_assets + .iter() + .filter(|a| self.cloud.map_wanted.contains(&a.id)), + ) + .map(|a| (a.id.clone(), a.revision, a.thumbnail_url.clone())) + .collect(); + let visible: HashSet = wanted.iter().map(|(id, _, _)| id.clone()).collect(); + if self.cloud.thumbnails.len() > THUMBNAIL_CACHE { + self.cloud.thumbnails.retain(|id, _| visible.contains(id)); + } + self.cloud + .thumbnail_jobs + .retain(|(id, _)| visible.contains(id)); + self.cloud + .thumbnail_failed + .retain(|id| visible.contains(id)); + for (id, revision, url) in wanted { + if self.cloud.thumbnail_active >= THUMBNAIL_WORKERS { + break; + } + if self + .cloud + .thumbnails + .get(&id) + .is_some_and(|(r, _)| *r == revision) + { + continue; + } + let Some(url) = url else { + continue; + }; + if !self.cloud.thumbnail_jobs.insert((id.clone(), revision)) { + continue; + } + self.cloud.thumbnail_active += 1; + let sender = self.cloud.sender.clone(); + let epoch = self.cloud.epoch; + remote::runtime::spawn(async move { + let image = (async { + let bytes = remote::auth::download_limited_async(&url, 8 * 1024 * 1024).await?; + let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)) + .with_guessed_format()?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(4096); + limits.max_image_height = Some(4096); + limits.max_alloc = Some(64 * 1024 * 1024); + reader.limits(limits); + let image = reader.decode()?.thumbnail(256, 256).to_rgba8(); + rgba_to_render_image(image.width(), image.height(), image.into_raw()) + .ok_or_else(|| anyhow!("Invalid thumbnail")) + }) + .await + .map_err(|e| log::warn!("cloud: thumbnail for {id} failed: {e}")) + .ok(); + let _ = sender.send(Job::Thumbnail { + epoch, + id, + revision, + image, + }); + }); + } + } + fn cloud_tick(&mut self, cx: &mut Context) { + self.cloud_capture_edit(); + self.cloud_generation_tick(cx); + let mut changed = false; + while let Ok(job) = self.cloud.jobs.try_recv() { + changed = true; + match job { + Job::Thumbnail { + epoch, + id, + revision, + image, + } if epoch == self.cloud.epoch => { + self.cloud.thumbnail_active = self.cloud.thumbnail_active.saturating_sub(1); + match image { + Some(image) => { + self.cloud.thumbnail_failed.remove(&id); + self.cloud.thumbnails.insert(id, (revision, image)); + } + None => { + self.cloud.thumbnail_failed.insert(id); + } + } + } + #[cfg(not(target_arch = "wasm32"))] + Job::Browser { epoch, url } if epoch == self.cloud.epoch => cx.open_url(&url), + Job::SignedIn { epoch, account } if epoch == self.cloud.epoch => { + self.cloud_connect(account, cx) + } + Job::Error { epoch, error } if epoch == self.cloud.epoch => { + self.cloud.progress = None; + if error.starts_with("Not enough cloud storage.") { + self.open_modal( + Modal::Cloud { + kind: "storage-warning", + fields: vec![("message", String::new(), error.clone())], + }, + cx, + ); + } + self.cloud_error(error) + } + Job::Done { epoch, message } if epoch == self.cloud.epoch => { + self.cloud.progress = None; + self.status = message.clone().into(); + self.cloud.message = message; + } + Job::Progress { + epoch, + done, + total, + label, + } if epoch == self.cloud.epoch => { + self.status = label.clone().into(); + self.cloud.message = label.clone(); + self.cloud.progress = Some((done, total, label)); + } + #[cfg(not(target_arch = "wasm32"))] + Job::MapAssets { epoch, key, result } if epoch == self.cloud.epoch => { + self.cloud.map_loading = false; + // A failed fetch still records its key, so the map + // does not ask again every frame; the next change + // or scope retries. + self.cloud.map_key = Some(key); + match result { + Ok(assets) => self.cloud.map_assets = assets, + Err(error) => self.cloud_error(format!("World map: {error}")), + } + } + #[cfg(not(target_arch = "wasm32"))] + Job::Batch { epoch, paths } if epoch == self.cloud.epoch => { + self.open_batch_process(paths, cx); + self.update_modal(|m| { + if let Modal::BatchProcess { target, .. } = m { + *target = super::BatchTarget::Folder; + } + }); + } + Job::Opened { + epoch, + mut asset, + download, + } if epoch == self.cloud.epoch => { + asset.name = download.suggested_name(&asset.name); + asset.revision = download.revision; + if let Some(mime) = &download.content_type { + asset.mime_type = mime.clone(); + } + if let Err(e) = self.cloud_install(asset, download.bytes, cx) { + self.cloud_error(format!("Open cloud document: {e}")); + } + } + Job::Downloaded { + epoch, + name, + download, + } if epoch == self.cloud.epoch => { + self.cloud_save_download(name, download, cx); + } + Job::Uploaded { epoch, doc, asset } if epoch == self.cloud.epoch => { + if asset + .moderation + .as_ref() + .is_some_and(|m| m.status != "clear") + { + self.status = "Uploaded — screening before it appears in Schist Cloud. This document remains local; open its cloud copy after screening.".into(); + continue; + } + if let Some(source) = self.cloud_doc(doc) { + match remote::document::SharedDocument::unseeded(source) { + Ok(shared) => { + self.cloud_close_document(doc); + self.cloud.docs.insert( + doc, + RemoteDocument { + asset, + shared, + joined: false, + detached: false, + vector: vec![0], + sending: false, + changed: true, + generation: 0, + saved: 0, + render: false, + }, + ); + self.cloud_join(doc); + } + Err(e) => self.cloud_error(e.to_string()), + } + } + } + _ => {} + } + } + let events: Vec<_> = self + .cloud + .client + .as_ref() + .map(|c| c.events.try_iter().collect()) + .unwrap_or_default(); + for event in events { + changed = true; + if let Err(e) = self.cloud_event(event, cx) { + self.cloud_error(e.to_string()); + } + } + // Capture before applying remote state, so local changes made in the same UI tick survive. + self.cloud_capture_edit(); + if !self.pointer_down && self.modal.is_none() { + let ids: Vec<_> = self + .cloud + .docs + .iter() + .filter(|(_, d)| d.render) + .map(|(id, _)| *id) + .collect(); + for id in ids { + if let Err(e) = self.cloud_render_document(id) { + self.cloud_error(e.to_string()); + } + changed = true; + } + } + let ids: Vec<_> = self.cloud.docs.keys().copied().collect(); + for id in ids { + self.cloud_send_document(id); + } + self.cloud_load_thumbnails(); + self.cloud_persist_credentials(cx); + if changed { + cx.notify(); + } + } + #[cfg(not(target_arch = "wasm32"))] + fn cloud_persist_credentials(&mut self, cx: &mut Context) { + if self.cloud.writing { + return; + } + let Some(account) = self.cloud.writes.pop_front() else { + return; + }; + self.cloud.writing = true; + let task = match account { + Some(account) => match serde_json::to_vec(&account) { + Ok(data) => cx.write_credentials(CREDENTIAL_KEY, &account.domain, &data), + Err(e) => { + self.cloud.writing = false; + self.cloud_error(e.to_string()); + return; + } + }, + None => cx.delete_credentials(CREDENTIAL_KEY), + }; + cx.spawn(async move |this, cx| { + let result = task.await; + let _ = this.update(cx, |ws, cx| { + ws.cloud.writing = false; + if let Err(e) = result { + ws.cloud_error(format!("Could not persist cloud login: {e}")); + } + cx.notify(); + }); + }) + .detach(); + } + #[cfg(target_arch = "wasm32")] + fn cloud_persist_credentials(&mut self, _: &mut Context) { + self.cloud.writes.clear(); + } + fn cloud_event(&mut self, event: Event, cx: &mut Context) -> Result<()> { + match event { + Event::Connected => { + self.cloud.connected = true; + self.cloud.load_error = None; + self.cloud.capabilities = None; + self.cloud.capabilities_ready = false; + self.cloud.message = "Connected to Schist Cloud".into(); + if let Some(client) = &self.cloud.client { + let id = client.handle.call("workspace.capabilities", map([])); + self.cloud.pending.insert(id, Pending::Capabilities); + } + } + Event::AccountUnavailable => { + self.cloud.disconnect(); + self.cloud.epoch += 1; + self.cloud.account = None; + self.cloud.client = None; + self.cloud.writes.push_back(None); + self.cloud.assets.clear(); + self.cloud.folders.clear(); + self.cloud.buckets.clear(); + self.cloud.thumbnails.clear(); + self.cloud.selected.clear(); + self.cloud.people = None; + self.cloud.total = 0; + self.cloud.library_total = None; + for doc in self.cloud.docs.values_mut() { + doc.detach(); + } + self.cloud_error("Cloud access is unavailable. Sign in again to check your account; local files remain on this device."); + } + Event::Disconnected(error) => { + self.cloud.disconnect(); + if !self.cloud.loaded { + self.cloud.load_error = Some(error.clone()); + } + self.cloud.message = error; + } + Event::Credentials(account) => { + self.cloud.account = Some(account.clone()); + self.cloud.writes.push_back(Some(account)); + } + Event::Snapshot { + subscription_id, + snapshot, + } => { + if let Some(screening) = snapshot.screening { + self.cloud.screening = screening; + } + if subscription_id == self.cloud.watching { + self.cloud.people = snapshot.people; + } + match subscription_id.as_str() { + id if id == self.cloud.folders_watch => { + self.cloud.library_total = snapshot.library_asset_count; + self.cloud.folders = snapshot + .items + .into_iter() + .map(parse) + .collect::>()?; + self.cloud.folders_total = snapshot.total; + } + id if id == self.cloud.buckets_watch => { + self.cloud.buckets = snapshot + .items + .into_iter() + .map(parse) + .collect::>()?; + self.cloud.buckets_total = snapshot.total; + } + id if id == self.cloud.watching => { + self.cloud.assets = snapshot + .items + .into_iter() + .map(parse) + .collect::>()?; + self.cloud.total = snapshot.total; + self.cloud.loaded = true; + self.cloud.load_error = None; + self.cloud.changes += 1; + if std::mem::take(&mut self.cloud.select_all_pending) { + self.cloud.selected = self.cloud_flat_order(); + self.cloud.select_anchor = self.cloud.selected.first().cloned(); + } + let ids: HashSet<_> = + self.cloud.assets.iter().map(|a| a.id.clone()).collect(); + self.cloud.selected.retain(|id| ids.contains(id)); + if self + .cloud + .select_anchor + .as_ref() + .is_some_and(|id| !ids.contains(id)) + { + self.cloud.select_anchor = None; + } + } + _ => {} + } + } + Event::WatchError { + subscription_id, + error, + } => { + if subscription_id == self.cloud.watching { + self.cloud.assets.clear(); + self.cloud.total = 0; + self.cloud.loaded = false; + self.cloud.load_error = Some(error.clone()); + } + self.cloud_error(error); + } + Event::DocumentUpdate { asset_id, bytes } => { + self.cloud.apply_document_update(&asset_id, &bytes)?; + } + Event::DocumentError { asset_id, error } => { + if let Some(id) = self.cloud.detach_document(&asset_id) { + if self.close_after_save == Some(id) { + self.close_after_save = None; + self.cancel_quit(); + } + } + self.cloud_error(error); + } + Event::Reply { id, result } => { + let Some(p) = self.cloud.pending.remove(&id) else { + return Ok(()); + }; + match (p, result) { + (Pending::Capabilities, result) => { + let capabilities = remote::Capabilities::from_reply(result)?; + if let Some(client) = &self.cloud.client { + client.handle.set_frame_limit( + capabilities + .as_ref() + .map_or(remote::MAX_FRAME, |c| c.frame_limit()), + ); + } + self.cloud.capabilities = capabilities; + self.cloud.capabilities_ready = true; + let ids = self.cloud.joinable_documents(); + for id in ids { + self.cloud_join(id); + } + } + (Pending::Join(id), Ok(result)) => { + if let Some(d) = self.cloud.docs.get_mut(&id) { + d.shared.apply(&bytes(&result, "update")?)?; + d.shared.seed_if_empty()?; + d.vector = bytes(&result, "state_vector")?; + d.joined = true; + d.changed = true; + d.render = true; + } + } + ( + Pending::Update { + doc, + vector, + generation, + }, + Ok(_), + ) => { + if let Some(d) = self.cloud.docs.get_mut(&doc) { + d.sending = false; + d.vector = vector; + d.saved = generation; + let saved = d.saved == d.generation; + if saved { + if let Some(doc) = self.cloud_doc_mut(doc) { + doc.mark_saved(); + } + } + } + if self.close_after_save == Some(doc) + && self + .cloud + .docs + .get(&doc) + .is_some_and(|d| d.saved == d.generation) + { + self.cloud_finish_save(doc, cx); + } + } + (Pending::Update { doc, .. }, Err(error)) => { + if let Some(d) = self.cloud.docs.get_mut(&doc) { + d.sending = false; + d.joined = false; + } + self.cloud_error(error); + } + (_, Err(error)) => self.cloud_error(error), + (_, Ok(_)) => { + self.cloud.message = "Cloud updated".into(); + } + } + } + } + Ok(()) + } + fn cloud_doc(&self, id: DocumentId) -> Option<&Document> { + self.doc.as_ref().filter(|d| d.id == id).or_else(|| { + self.background_tabs + .iter() + .find(|t| t.doc.id == id) + .map(|t| &t.doc) + }) + } + fn cloud_doc_mut(&mut self, id: DocumentId) -> Option<&mut Document> { + if self.doc.as_ref().is_some_and(|d| d.id == id) { + self.doc.as_mut() + } else { + self.background_tabs + .iter_mut() + .find(|t| t.doc.id == id) + .map(|t| &mut t.doc) + } + } + fn cloud_install(&mut self, asset: Asset, data: Vec, cx: &mut Context) -> Result<()> { + let mut doc = self + .registry + .codecs() + .find(|c| c.probe(&data)) + .ok_or_else(|| anyhow!("Unsupported remote file format"))? + .import(&data)?; + doc.title = asset.name.clone(); + doc.path = None; + #[allow(unused_mut)] + let mut shared = remote::document::SharedDocument::unseeded(&doc)?; + #[cfg(not(target_arch = "wasm32"))] + if let Ok(bytes) = std::fs::read(self.cloud_recovery_path(&asset.id)) { + match shared.restore(&bytes, &doc) { + Ok(mut recovered) => { + recovered.id = doc.id; + doc = recovered; + } + Err(e) => self.cloud_error(format!("Could not restore cloud edits: {e}")), + } + } + let id = doc.id; + let generation = u64::from(doc.dirty); + self.open_in_tab(doc, true); + self.cloud_set_visible(false); + self.cloud.docs.insert( + id, + RemoteDocument { + asset, + shared, + joined: false, + detached: false, + vector: vec![0], + sending: false, + changed: true, + generation, + saved: 0, + render: false, + }, + ); + self.cloud_join(id); + cx.notify(); + Ok(()) + } + pub(crate) fn cloud_open(&mut self, asset: Asset, cx: &mut Context) { + if let Some(id) = self + .cloud + .docs + .iter() + .find(|(_, d)| d.asset.id == asset.id) + .map(|(id, _)| *id) + { + if self.cloud.reopen_document(id) { + self.cloud_join(id); + } + if self.doc.as_ref().is_some_and(|d| d.id == id) { + self.cloud_set_visible(false); + } else if let Some(i) = self.background_tabs.iter().position(|t| t.doc.id == id) { + let index = if i >= self.active_tab { i + 1 } else { i }; + self.select_tab(index, cx); + self.cloud_set_visible(false); + } + cx.notify(); + return; + } + let Some(c) = &self.cloud.client else { + return; + }; + let handle = c.handle.clone(); + let sender = self.cloud.sender.clone(); + let epoch = self.cloud.epoch; + self.cloud.message = format!("Opening {}…", asset.name); + remote::runtime::spawn(async move { + let result = handle.download_asset_async(&asset.id, None, None).await; + let job = match result { + Ok(download) => Job::Opened { + epoch, + asset, + download, + }, + Err(e) => Job::Error { + epoch, + error: e.to_string(), + }, + }; + let _ = sender.send(job); + }); + cx.notify(); + } + fn cloud_join(&mut self, id: DocumentId) { + if !self.cloud.connected || !self.cloud.capabilities_ready { + return; + } + if self + .cloud + .capabilities + .as_ref() + .is_some_and(|c| !c.supports_image_model()) + { + self.cloud_error("This provider does not support collaborative image editing"); + return; + } + let Some(d) = self.cloud.docs.get_mut(&id) else { + return; + }; + if d.detached { + return; + } + if self + .cloud + .pending + .values() + .any(|p| matches!(p,Pending::Join(doc) if *doc==id)) + { + return; + } + d.joined = false; + d.sending = false; + if let Some(c) = &self.cloud.client { + let req = c.handle.call( + "document.join", + map([ + ("document_id", d.asset.id.clone().into()), + ("state_vector", Value::Binary(d.shared.state_vector())), + ]), + ); + self.cloud.pending.insert(req, Pending::Join(id)); + } + } + pub(crate) fn cloud_undo(&mut self, redo: bool, cx: &mut Context) -> bool { + let Some(id) = self.doc.as_ref().map(|d| d.id) else { + return false; + }; + self.cloud_capture_edit(); + let Some(d) = self.cloud.docs.get_mut(&id) else { + return false; + }; + if !d.joined { + self.cloud_error("Connect before undoing cloud edits"); + return true; + } + if d.shared.undo(redo) { + d.render = true; + d.changed = true; + d.generation += 1; + if let Err(e) = self.cloud_render_document(id) { + self.cloud_error(e.to_string()); + } + self.cloud_send_document(id); + cx.notify(); + } + true + } + pub(crate) fn cloud_capture_edit(&mut self) { + let Some(doc) = &self.doc else { + return; + }; + let id = doc.id; + let Some(d) = self.cloud.docs.get_mut(&id) else { + return; + }; + if d.shared.revision == doc.revision { + return; + } + match d.shared.local_changes(doc) { + Ok(Some(_)) => { + d.changed = true; + d.generation += 1; + } + Ok(None) => {} + Err(e) => { + self.cloud_error(format!("Cloud edit is not saved: {e}")); + } + } + } + fn cloud_send_document(&mut self, id: DocumentId) { + let Some(d) = self.cloud.docs.get_mut(&id) else { + return; + }; + if d.detached || !d.joined || d.sending || !d.changed { + return; + } + if let Some(capabilities) = &self.cloud.capabilities { + if let Err(error) = capabilities.check_document(d.shared.full_state().len()) { + d.joined = false; + self.cloud_error(error.to_string()); + return; + } + } + let update = match d.shared.diff(&d.vector) { + Ok(b) => b, + Err(e) => { + d.joined = false; + self.cloud_error(e.to_string()); + return; + } + }; + if let Some(c) = &self.cloud.client { + let vector = d.shared.state_vector(); + let generation = d.generation; + let req = c.handle.call( + "document.update", + map([ + ("document_id", d.asset.id.clone().into()), + ("update", Value::Binary(update)), + ]), + ); + d.sending = true; + d.changed = false; + self.cloud.pending.insert( + req, + Pending::Update { + doc: id, + vector, + generation, + }, + ); + } + } + fn cloud_render_document(&mut self, id: DocumentId) -> Result<()> { + let d = self + .cloud + .docs + .get_mut(&id) + .ok_or_else(|| anyhow!("No cloud document"))?; + let mut next = d.shared.render()?; + next.id = id; + next.dirty = d.generation > d.saved; + d.render = false; + if let Some(doc) = self.cloud_doc_mut(id) { + next.revision = doc.revision + 1; + next.active_layer = doc + .active_layer + .filter(|id| next.tree.find(*id).is_some()) + .or(next.active_layer); + next.selection = std::mem::take(&mut doc.selection); + next.last_selection = doc.last_selection.take(); + next.saved_selections = std::mem::take(&mut doc.saved_selections); + next.history_source = std::mem::take(&mut doc.history_source); + next.selected = doc + .selected + .iter() + .copied() + .filter(|id| next.tree.find(*id).is_some()) + .collect(); + next.active_path = doc.active_path.filter(|i| *i < next.paths.len()); + std::mem::swap(&mut next.history, &mut doc.history); + *doc = next; + } + if let Some(doc) = self.cloud_doc(id) { + let r = doc.revision; + if let Some(d) = self.cloud.docs.get_mut(&id) { + d.shared.revision = r; + } + } + if self.doc.as_ref().is_some_and(|d| d.id == id) { + self.reset_per_document_caches(); + self.refresh_layer_styles(); + } + Ok(()) + } + pub(crate) fn cloud_save(&mut self, cx: &mut Context) -> bool { + let Some(id) = self.doc.as_ref().map(|d| d.id) else { + return false; + }; + if !self.cloud.docs.contains_key(&id) { + return false; + } + if self + .cloud + .capabilities + .as_ref() + .is_some_and(|c| !c.supports_image_model()) + { + self.cloud_error("This provider does not support collaborative image editing; save a local copy instead"); + cx.notify(); + return true; + } + self.cloud_capture_edit(); + let remote = &self.cloud.docs[&id]; + if remote.detached { + self.cloud_error("Cloud sync has stopped for this document. Reopen it from the cloud gallery to reconnect; edits remain local."); + cx.notify(); + return true; + } + if remote.joined && !remote.sending && !remote.changed && remote.saved == remote.generation + { + if let Some(doc) = self.cloud_doc_mut(id) { + doc.mark_saved(); + } + self.cloud_finish_save(id, cx); + self.status = "Saved to Schist Cloud".into(); + cx.notify(); + return true; + } + if !self.cloud.docs[&id].joined { + self.cloud_join(id); + } + self.cloud_send_document(id); + self.status = if self.cloud.connected { + "Saving to Schist Cloud…" + } else { + "Offline — cloud edits will sync after reconnecting" + } + .into(); + cx.notify(); + true + } + fn cloud_finish_save(&mut self, id: DocumentId, cx: &mut Context) { + if self.close_after_save != Some(id) { + return; + } + self.close_after_save = None; + let index = if self.doc.as_ref().is_some_and(|doc| doc.id == id) { + Some(self.active_tab) + } else { + self.background_tabs + .iter() + .position(|tab| tab.doc.id == id) + .map(|i| if i >= self.active_tab { i + 1 } else { i }) + }; + if let Some(index) = index { + self.close_tab(index, cx); + self.resume_quit(cx); + } + } + pub(crate) fn cloud_close_document(&mut self, id: DocumentId) { + self.cloud + .pending + .retain(|_, pending| !pending.belongs_to(id)); + if let Some(d) = self.cloud.docs.remove(&id) { + #[cfg(not(target_arch = "wasm32"))] + let _ = self + .cloud + .recovery + .send(RecoveryTask::Remove(self.cloud_recovery_path(&d.asset.id))); + if let Some(c) = &self.cloud.client { + c.handle + .call("document.leave", map([("document_id", d.asset.id.into())])); + } + } + } + #[cfg(not(target_arch = "wasm32"))] + fn cloud_recovery_path(&self, asset: &str) -> PathBuf { + use sha2::Digest; + let domain = self + .cloud + .account + .as_ref() + .map(|a| a.domain.as_str()) + .unwrap_or(""); + state_dir().join(format!( + "{:x}.msgpack", + sha2::Sha256::digest(format!("{domain}\n{asset}")) + )) + } + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn cloud_checkpoint(&mut self) { + self.cloud_capture_edit(); + let snapshots = self + .cloud + .docs + .values() + .map(|d| { + Ok(( + self.cloud_recovery_path(&d.asset.id), + d.shared.checkpoint()?, + )) + }) + .collect::>>(); + match snapshots { + Ok(files) => { + let _ = self.cloud.recovery.send(RecoveryTask::Write { + epoch: self.cloud.epoch, + files, + }); + } + Err(e) => self.cloud_error(format!("Cloud recovery failed: {e}")), + } + } + #[cfg(target_arch = "wasm32")] + pub(crate) fn cloud_checkpoint(&mut self) { + self.cloud_capture_edit(); + } + /// Create or update a cloud bucket from the shared bucket dialog: + /// the name, and a rule made of the search text and the drawn area. + /// `form_target` names the bucket being edited (none for a new + /// one); `form_scope` is what the rule searches. Editing keeps any + /// other filters the bucket's rule already had. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + pub(crate) fn cloud_save_bucket( + &mut self, + name: String, + text: Option, + bounds: Option, + ) { + let name = match name.trim() { + "" => format!("Bucket {}", self.cloud.buckets.len() + 1), + typed => typed.to_string(), + }; + let target = self.cloud.form_target.take(); + let mut filters = target + .as_ref() + .and_then(|(id, _)| self.cloud.buckets.iter().find(|b| &b.id == id)) + .and_then(|b| b.rule.as_ref()) + .map(|r| r.filters.clone()) + .unwrap_or_default(); + filters.bounds = bounds; + let rule = if text.is_none() && filters == Filters::default() { + Value::Nil + } else { + value(remote::Rule { + scope: self.cloud.form_scope.clone(), + text: text.unwrap_or_default(), + filters, + }) + }; + let mut params = vec![("name", name.into()), ("rule", rule)]; + let method = match target { + Some((id, revision)) => { + params.extend([("id", id.into()), ("revision", revision.into())]); + "bucket.update" + } + None => "bucket.create", + }; + self.cloud_mutate(method, params); + } + /// "Select all" on a bucket row: show the bucket and select its + /// page — now if it is already on screen, on arrival otherwise. + pub(crate) fn cloud_select_all_bucket(&mut self, id: String, cx: &mut Context) { + let showing = self.cloud.show + && self.cloud.loaded + && matches!(&self.cloud.query.scope, Scope::Bucket { id: on } if on == &id); + if showing { + self.cloud.selected = self.cloud_flat_order(); + self.cloud.select_anchor = self.cloud.selected.first().cloned(); + cx.notify(); + return; + } + self.cloud_browse(Scope::Bucket { id }, cx); + self.cloud.select_all_pending = true; + } + /// Drop every hand-added member; a smart rule's matches stay. + pub(crate) fn cloud_clear_bucket(&mut self, bucket: &Bucket) { + self.cloud_mutate( + "bucket.clear", + vec![ + ("id", bucket.id.clone().into()), + ("revision", bucket.revision.into()), + ], + ); + } + /// Ask which cloud folder a bucket's photos should be filed into. + pub(crate) fn cloud_move_bucket(&mut self, bucket: &Bucket, cx: &mut Context) { + self.cloud.form_target = Some((bucket.id.clone(), bucket.revision)); + self.open_modal( + Modal::Cloud { + kind: "move-items", + fields: vec![("cloud-folder", "Folder".into(), String::new())], + }, + cx, + ); + } + /// Every photo a bucket holds — hand-added and rule-matched — + /// walked page by page through the provider's query request. + #[cfg(not(target_arch = "wasm32"))] + fn cloud_client_for_bucket( + &mut self, + ) -> Option<(remote::Handle, Option)> { + let Some(c) = &self.cloud.client else { + self.cloud_error("Sign in first"); + return None; + }; + if !self.cloud.connected { + self.cloud_error("Wait for the cloud connection"); + return None; + } + Some((c.handle.clone(), self.cloud.capabilities.clone())) + } + /// Keep the world map's located assets current for the scope and + /// search on show: one fetch per change, the whole scope rather than + /// the page, only photos with a valid fix. Called from the map's + /// render. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn cloud_map_refresh(&mut self) { + if !self.cloud.show || !self.cloud.connected || self.cloud.map_loading { + return; + } + let mut query = self.cloud.query.clone(); + query.offset = 0; + query.limit = 500; + query.sort = "captured_desc".into(); + if query.filters.bounds.is_none() { + // The provider's bounds filter is also its "has a fix" test. + query.filters.bounds = Some(remote::Bounds { + south: -90.0, + north: 90.0, + west: -180.0, + east: 180.0, + }); + } + let key = (query.clone(), self.cloud.changes); + if self.cloud.map_key.as_ref() == Some(&key) { + return; + } + let Some(c) = &self.cloud.client else { + return; + }; + let handle = c.handle.clone(); + let sender = self.cloud.sender.clone(); + let epoch = self.cloud.epoch; + self.cloud.map_loading = true; + remote::runtime::spawn(async move { + let result = query_assets(&handle, query, MAP_ASSET_CAP) + .await + .map_err(|e| e.to_string()); + let _ = sender.send(Job::MapAssets { epoch, key, result }); + }); + } + /// Right-click ▸ Download folder…: every photo in a cloud folder + /// (or the whole library) into a folder of the user's choosing, the + /// cloud's sub-folders recreated beneath it. Edited photos come as + /// the provider's default export, the rest as their originals. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn cloud_download_scope(&mut self, scope: Scope, cx: &mut Context) { + let Some((handle, capabilities)) = self.cloud_client_for_bucket() else { + cx.notify(); + return; + }; + let prompt = cx.prompt_for_paths(gpui::PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: Some("Download Here".into()), + }); + let sender = self.cloud.sender.clone(); + let epoch = self.cloud.epoch; + let folders = self.cloud.folders.clone(); + let root = match &scope { + Scope::Folder { id, .. } => Some(id.clone()), + _ => None, + }; + cx.spawn(async move |_this, _cx| { + let Ok(Ok(Some(mut dirs))) = prompt.await else { + return; + }; + let Some(dest) = dirs.pop() else { return }; + remote::runtime::spawn(async move { + let result: Result = (async { + let assets = scope_assets(&handle, &scope).await?; + let total = assets.len(); + let mut names: HashSet = HashSet::new(); + for (done, asset) in assets.into_iter().enumerate() { + let _ = sender.send(Job::Done { + epoch, + message: format!("Downloading {} of {total}\u{2026}", done + 1), + }); + let format = if asset.edited { + capabilities + .as_ref() + .map(|c| c.default_edited_export.clone()) + .filter(|f| { + capabilities.as_ref().is_some_and(|c| c.supports_export(f)) + }) + } else { + None + }; + let download = handle + .download_asset_async( + &asset.id, + format.as_deref(), + capabilities.as_ref(), + ) + .await?; + let mut dir = dest.clone(); + for name in + folder_path_below(&folders, root.as_deref(), asset.folder_id.as_deref()) + { + dir.push(safe_component(&name)); + } + std::fs::create_dir_all(&dir)?; + let name = download.suggested_name(&asset.name); + let mut path = dir.join(safe_component(&name)); + if !names.insert(path.clone()) || path.exists() { + path = dir.join(format!("{}-{}", done + 1, safe_component(&name))); + names.insert(path.clone()); + } + std::fs::write(&path, download.bytes)?; + } + Ok(total) + }) + .await; + let job = match result { + Ok(n) => Job::Done { + epoch, + message: format!("Downloaded {n} photos to {}", dest.display()), + }, + Err(e) => Job::Error { + epoch, + error: format!("Download failed (finished files remain): {e}"), + }, + }; + let _ = sender.send(job); + }); + }) + .detach(); + self.cloud.message = "Gathering the folder\u{2026}".into(); + cx.notify(); + } + /// Right-click ▸ Save all as ZIP…: one archive of the bucket's + /// photos — edited ones as the provider's default export, the rest + /// as their originals — built straight from the downloads. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn cloud_zip_bucket(&mut self, bucket: Bucket, cx: &mut Context) { + let Some((handle, capabilities)) = self.cloud_client_for_bucket() else { + cx.notify(); + return; + }; + let suggested = format!("{}.zip", bucket.name.to_lowercase().replace(' ', "-")); + let directory = std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + let prompt = cx.prompt_for_new_path(&directory, Some(&suggested)); + let sender = self.cloud.sender.clone(); + let epoch = self.cloud.epoch; + cx.spawn(async move |_this, _cx| { + let Ok(Ok(Some(out))) = prompt.await else { + return; + }; + remote::runtime::spawn(async move { + let progress = |message: String| { + let _ = sender.send(Job::Done { epoch, message }); + }; + let result: Result = (async { + let scope = Scope::Bucket { + id: bucket.id.clone(), + }; + let assets = scope_assets(&handle, &scope).await?; + let total = assets.len(); + let mut writer = super::library_ops::ZipWriter::create(&out)?; + let mut names = HashSet::new(); + for (done, asset) in assets.into_iter().enumerate() { + progress(format!("Zipping {} of {total}\u{2026}", done + 1)); + let format = if asset.edited { + capabilities + .as_ref() + .map(|c| c.default_edited_export.clone()) + .filter(|f| { + capabilities.as_ref().is_some_and(|c| c.supports_export(f)) + }) + } else { + None + }; + let download = handle + .download_asset_async( + &asset.id, + format.as_deref(), + capabilities.as_ref(), + ) + .await?; + let mut name = download.suggested_name(&asset.name); + if !names.insert(name.clone()) { + name = format!("{}-{name}", done + 1); + names.insert(name.clone()); + } + writer.add(&name, &download.bytes)?; + } + writer.finish()?; + Ok(total) + }) + .await; + let job = match result { + Ok(n) => Job::Done { + epoch, + message: format!("Saved {n} photos to {}", out.display()), + }, + Err(e) => Job::Error { + epoch, + error: format!("ZIP failed: {e}"), + }, + }; + let _ = sender.send(job); + }); + }) + .detach(); + self.cloud.message = "Gathering the bucket\u{2026}".into(); + cx.notify(); + } + /// Right-click ▸ Process all…: the bucket's originals land in a + /// scratch folder, then the batch dialog runs over them; results + /// save to a folder of the user's choosing. + #[cfg(not(target_arch = "wasm32"))] + pub(crate) fn cloud_process_bucket(&mut self, bucket: Bucket, cx: &mut Context) { + let Some((handle, capabilities)) = self.cloud_client_for_bucket() else { + cx.notify(); + return; + }; + let sender = self.cloud.sender.clone(); + let epoch = self.cloud.epoch; + let dir = batch_dir().join(remote::Uuid::new_v4().to_string()); + remote::runtime::spawn(async move { + let result: Result> = (async { + let scope = Scope::Bucket { + id: bucket.id.clone(), + }; + let assets = scope_assets(&handle, &scope).await?; + anyhow::ensure!(!assets.is_empty(), "This bucket is empty"); + std::fs::create_dir_all(&dir)?; + let total = assets.len(); + let mut paths = Vec::with_capacity(total); + for (done, asset) in assets.into_iter().enumerate() { + let _ = sender.send(Job::Done { + epoch, + message: format!("Fetching {} of {total}\u{2026}", done + 1), + }); + let download = handle + .download_asset_async(&asset.id, None, capabilities.as_ref()) + .await?; + let mut path = dir.join(download.suggested_name(&asset.name)); + if path.exists() { + path = dir.join(format!( + "{}-{}", + done + 1, + download.suggested_name(&asset.name) + )); + } + std::fs::write(&path, download.bytes)?; + paths.push(path); + } + Ok(paths) + }) + .await; + let job = match result { + Ok(paths) => Job::Batch { epoch, paths }, + Err(e) => Job::Error { + epoch, + error: format!("Could not fetch the bucket: {e}"), + }, + }; + let _ = sender.send(job); + }); + self.cloud.message = "Gathering the bucket\u{2026}".into(); + cx.notify(); + } + pub(crate) fn cloud_mutate(&mut self, method: &str, fields: Vec<(&'static str, Value)>) { + if let Some(c) = &self.cloud.client { + let mut fields = fields; + fields.push(("mutation_id", remote::Uuid::new_v4().to_string().into())); + let id = c.handle.call(method, map(fields)); + self.cloud.pending.insert(id, Pending::Mutation); + } + } + pub(crate) fn cloud_drop_remote(&mut self, bucket: String, items: Vec) { + self.cloud_mutate( + "bucket.add", + vec![("id", bucket.into()), ("items", Value::Array(items))], + ); + } + /// The strip's Upload buttons: into whatever is on show. + pub(crate) fn cloud_pick_upload(&mut self, directory: bool, cx: &mut Context) { + let (bucket, folder) = match &self.cloud.query.scope { + Scope::Bucket { id } => (Some(id.clone()), None), + Scope::Folder { id, .. } => (None, Some(id.clone())), + _ => (None, None), + }; + self.cloud_pick_upload_to(bucket, folder, directory, cx); + } + /// Pick files or a folder and upload them into a bucket or folder. + pub(crate) fn cloud_pick_upload_to( + &mut self, + bucket: Option, + folder: Option, + directory: bool, + cx: &mut Context, + ) { + #[cfg(not(target_arch = "wasm32"))] + let prompt = { + let picker = cx.prompt_for_paths(gpui::PathPromptOptions { + files: !directory, + directories: directory, + multiple: true, + prompt: Some("Upload to Schist Cloud".into()), + }); + async move { picker.await? } + }; + #[cfg(target_arch = "wasm32")] + let prompt = crate::web::pick_cloud_files(directory); + cx.spawn(async move |this, cx| { + let result = prompt.await; + let _ = this.update(cx, |ws, cx| { + match result { + Ok(Some(paths)) => ws.cloud_drop_local(bucket, folder, paths, cx), + Ok(None) => {} + Err(error) => ws.cloud_error(error.to_string()), + } + cx.notify(); + }); + }) + .detach(); + } + pub(crate) fn cloud_drop_local( + &mut self, + bucket: Option, + folder: Option, + paths: Vec, + cx: &mut Context, + ) { + let Some(c) = &self.cloud.client else { + return; + }; + let handle = c.handle.clone(); + let sender = self.cloud.sender.clone(); + let epoch = self.cloud.epoch; + self.cloud.message = "Uploading files…".into(); + self.cloud.progress = Some((0, 0, "Looking through the files…".into())); + remote::runtime::spawn(async move { + let progress = |done: u64, total: u64, label: String| { + let _ = sender.send(Job::Progress { + epoch, + done, + total, + label, + }); + }; + let result: Result = (async { + let mut files = Vec::new(); + for path in paths { + if path.is_dir() { + enumerate_files(&path, &path, &mut files)?; + } else { + #[cfg(not(target_arch = "wasm32"))] + let relative = None; + #[cfg(target_arch = "wasm32")] + let relative = crate::web::cloud_relative_path(&path); + files.push((path, relative)); + } + } + progress(0, files.len() as u64, "Checking cloud storage…".into()); + let mut selection_bytes = 0u64; + let mut candidates = Vec::new(); + for (path, relative) in &files { + #[cfg(not(target_arch = "wasm32"))] + let size = std::fs::metadata(path) + .map(|metadata| metadata.len()) + .unwrap_or(0); + #[cfg(target_arch = "wasm32")] + let size = crate::web::read_file(path) + .map(|bytes| bytes.len() as u64) + .unwrap_or(0); + if remote::validate_upload_size(size).is_err() { + continue; + } + selection_bytes = selection_bytes + .checked_add(size) + .ok_or_else(|| anyhow!("Selection is too large"))?; + if size > remote::MAX_SINGLE_UPLOAD_BYTES { + candidates.push((path, relative, size)); + } + } + let initial: remote::multipart::UploadCapacity = parse( + handle + .request_async( + "asset.check_upload", + map([("bytes", selection_bytes.into())]), + ) + .await?, + )?; + if !initial.fits { + let mut resumes = Vec::new(); + for (path, relative, size) in candidates { + #[cfg(not(target_arch = "wasm32"))] + let resume_key = remote::multipart::resume_key_for_path( + path, + mime(path), + folder.as_deref(), + relative.as_deref(), + )?; + #[cfg(target_arch = "wasm32")] + let resume_key = remote::multipart::resume_key_for_bytes( + &crate::web::read_file(path)?, + &path.file_name().unwrap_or_default().to_string_lossy(), + mime(path), + folder.as_deref(), + relative.as_deref(), + ); + resumes.push(map([ + ("resume_key", resume_key.into()), + ("size", size.into()), + ])); + } + let capacity: remote::multipart::UploadCapacity = parse( + handle + .request_async( + "asset.check_upload", + map([ + ("bytes", selection_bytes.into()), + ("resumes", Value::Array(resumes)), + ]), + ) + .await?, + )?; + capacity.require_space()?; + } + let total = files.len() as u64; + progress(0, total, format!("Uploading 0 of {total} photos…")); + // A pipeline: files are read and packed into compressed + // batches ahead of the network, a few at a time, while + // one batch at a time goes up. The provider's batch + // support is learned from the first reply and shared + // back to the packer, so raw files stop being kept once + // payloads are known to be enough. + let support = Arc::new(std::sync::atomic::AtomicU8::new(SUPPORT_UNKNOWN)); + let mut uploader = Uploader { + handle: handle.clone(), + folder: folder.clone(), + support: support.clone(), + sender: sender.clone(), + epoch, + total, + done: 0, + uploaded: Vec::new(), + existing: Vec::new(), + skipped: Vec::new(), + dedupe: SUPPORT_UNKNOWN, + }; + let preparer = Preparer::new(files, support); + #[cfg(not(target_arch = "wasm32"))] + { + let (tx, rx) = std::sync::mpsc::sync_channel::(PREPARE_AHEAD); + std::thread::spawn(move || { + let mut preparer = preparer; + while let Some(item) = preparer.next() { + if tx.send(item).is_err() { + break; + } + } + }); + while let Ok(item) = rx.recv() { + uploader.take(item).await?; + } + } + #[cfg(target_arch = "wasm32")] + { + let mut preparer = preparer; + while let Some(item) = preparer.next() { + uploader.take(item).await?; + } + } + if let Some(bucket) = bucket { + uploader.report("Adding to the bucket…".into()); + let members: Vec = uploader + .uploaded + .iter() + .chain(uploader.existing.iter()) + .cloned() + .collect(); + for chunk in members.chunks(1000) { + let mutation = remote::Uuid::new_v4().to_string(); + uploader + .retrying("adding to the bucket", || { + let items = chunk + .iter() + .map(|id| { + map([("kind", "asset".into()), ("id", id.clone().into())]) + }) + .collect(); + handle.request_async( + "bucket.add", + map([ + ("id", bucket.clone().into()), + ("items", Value::Array(items)), + ("mutation_id", mutation.clone().into()), + ]), + ) + }) + .await?; + } + } + let Uploader { + uploaded, + existing, + skipped, + .. + } = uploader; + Ok(UploadSummary { + uploaded: uploaded.len(), + existing: existing.len(), + skipped, + }) + }) + .await; + let job = match result { + Ok(summary) => Job::Done { + epoch, + message: summary.message(), + }, + Err(e) => Job::Error { + epoch, + error: if e.to_string().starts_with("Not enough cloud storage.") { + e.to_string() + } else { + format!("Upload failed (completed files remain in Cloud): {e}") + }, + }, + }; + let _ = sender.send(job); + }); + cx.notify(); + } + pub(crate) fn cloud_upload_document(&mut self, cx: &mut Context) { + if self.cloud.account.is_none() { + self.cloud_sign_in(cx); + return; + } + let folder = match &self.cloud.query.scope { + Scope::Folder { id, .. } => id.clone(), + _ => String::new(), + }; + self.open_modal( + Modal::Cloud { + kind: "upload-document", + fields: vec![( + "cloud-folder", + "Folder ID (empty for unfiled)".into(), + folder, + )], + }, + cx, + ); + } + pub(crate) fn cloud_download_selected(&mut self, cx: &mut Context) { + if !self.cloud.connected || !self.cloud.capabilities_ready { + self.cloud_error("Wait for the cloud connection before downloading"); + cx.notify(); + return; + } + if self.cloud.selected.len() != 1 { + self.cloud_error("Select one cloud photo to download"); + cx.notify(); + return; + } + self.cloud.download_target = self + .cloud + .assets + .iter() + .find(|asset| self.cloud.selected.contains(&asset.id)) + .cloned(); + self.open_modal( + Modal::Cloud { + kind: "download", + fields: vec![("cloud-download-format", "Format".into(), String::new())], + }, + cx, + ); + } + fn cloud_start_download(&mut self, format: Option) -> Result<()> { + let asset = self + .cloud + .download_target + .clone() + .ok_or_else(|| anyhow!("Select a cloud photo first"))?; + let handle = self + .cloud + .client + .as_ref() + .ok_or_else(|| anyhow!("Sign in first"))? + .handle + .clone(); + let capabilities = self.cloud.capabilities.clone(); + let epoch = self.cloud.epoch; + let sender = self.cloud.sender.clone(); + self.cloud.message = format!("Downloading {}…", asset.name); + remote::runtime::spawn(async move { + let job = match handle + .download_asset_async(&asset.id, format.as_deref(), capabilities.as_ref()) + .await + { + Ok(download) => Job::Downloaded { + epoch, + name: download.suggested_name(&asset.name), + download, + }, + Err(error) => Job::Error { + epoch, + error: format!("Cloud download failed: {error}"), + }, + }; + let _ = sender.send(job); + }); + Ok(()) + } + #[cfg(not(target_arch = "wasm32"))] + fn cloud_save_download( + &mut self, + name: String, + download: DownloadedAsset, + cx: &mut Context, + ) { + let directory = std::env::var_os("HOME") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(".")); + let prompt = cx.prompt_for_new_path(&directory, Some(&name)); + cx.spawn(async move |this, cx| { + let Ok(Ok(Some(path))) = prompt.await else { + return; + }; + let result = cx + .background_executor() + .spawn(async move { std::fs::write(&path, download.bytes).map(|()| path) }) + .await; + let _ = this.update(cx, |ws, cx| { + match result { + Ok(path) => { + ws.cloud.message = format!("Downloaded {}", path.display()); + ws.status = ws.cloud.message.clone().into(); + } + Err(error) => ws.cloud_error(format!("Could not save download: {error}")), + } + cx.notify(); + }); + }) + .detach(); + } + #[cfg(target_arch = "wasm32")] + fn cloud_save_download( + &mut self, + name: String, + download: DownloadedAsset, + cx: &mut Context, + ) { + match crate::web::download_bytes(&name, &download.bytes) { + Ok(()) => self.cloud.message = format!("Downloaded {name}"), + Err(e) => self.cloud_error(e.to_string()), + } + cx.notify(); + } + fn cloud_upload_current(&mut self, folder: Option) -> Result<()> { + let doc = self + .doc + .as_ref() + .ok_or_else(|| anyhow!("Open a document first"))?; + let data = schist_codec_psd::write_psd(doc)?; + let id = doc.id; + let name = format!("{}.psd", doc.title.trim_end_matches(".psd")); + let handle = self + .cloud + .client + .as_ref() + .ok_or_else(|| anyhow!("Sign in first"))? + .handle + .clone(); + let sender = self.cloud.sender.clone(); + let epoch = self.cloud.epoch; + remote::runtime::spawn(async move { + let job = match handle + .upload_async(remote::Upload { + name: &name, + bytes: &data, + mime: "image/vnd.adobe.photoshop", + folder: folder.as_deref(), + asset: None, + relative: None, + mutation: &remote::Uuid::new_v4().to_string(), + }) + .await + { + Ok(asset) => Job::Uploaded { + epoch, + doc: id, + asset, + }, + Err(e) => Job::Error { + epoch, + error: e.to_string(), + }, + }; + let _ = sender.send(job); + }); + Ok(()) + } + pub(crate) fn cloud_submit( + &mut self, + kind: &str, + fields: Vec<(&'static str, String, String)>, + cx: &mut Context, + ) -> Result<()> { + let get = |key: &str| { + fields + .iter() + .find(|(k, _, _)| *k == key) + .map(|(_, _, v)| v.trim().to_string()) + .unwrap_or_default() + }; + if super::cloud_people::submit(self, kind, get)? { + return Ok(()); + } + match kind { + "download" => { + let format = get("cloud-download-format"); + self.cloud_start_download((!format.is_empty()).then_some(format))?; + } + "sign-in" => { + let domain = remote::auth::domain(&get("cloud-domain"))?; + self.cloud_login(domain, cx); + } + "search" => { + let mut q = self.cloud.query.clone(); + q.text = get("cloud-query"); + self.cloud.search.set_text(q.text.clone()); + q.offset = 0; + q.filters = parse_filters(&fields)?; + if let Some(r) = q.filters.min_rating { + anyhow::ensure!(r <= 5, "Rating must be 0–5"); + } + if let Some(c) = &q.filters.content { + anyhow::ensure!( + ["all", "safe", "flagged"].contains(&c.as_str()), + "Invalid content filter" + ); + } + self.cloud.query = q; + self.cloud_watch_assets(true); + } + "catalogue" => { + self.cloud.catalogue = get("cloud-query"); + self.cloud.folders_offset = 0; + self.cloud.buckets_offset = 0; + self.cloud_refresh_catalogue(); + } + "new-folder" | "new-subfolder" => { + // A folder made from the sidebar lands in the folder on + // show; one made from a folder's menu lands inside it. + let parent = if kind == "new-subfolder" { + self.cloud + .form_target + .take() + .map(|(id, _)| Value::from(id)) + .unwrap_or(Value::Nil) + } else { + match &self.cloud.query.scope { + Scope::Folder { id, .. } => id.clone().into(), + _ => Value::Nil, + } + }; + self.cloud_mutate( + "folder.create", + vec![("name", get("cloud-name").into()), ("parent_id", parent)], + ); + } + "delete-asset" => { + let (id, revision) = self + .cloud + .form_target + .clone() + .ok_or_else(|| anyhow!("No photo selected"))?; + self.cloud.selected.retain(|s| s != &id); + self.cloud_mutate( + "asset.delete", + vec![("id", id.into()), ("revision", revision.into())], + ); + } + "new-bucket" | "edit-bucket" => { + let filters = parse_filters(&fields)?; + let text = get("cloud-query"); + let rule = if text.is_empty() && filters == Filters::default() { + Value::Nil + } else { + let scope = if kind == "edit-bucket" { + self.cloud.form_scope.clone() + } else { + match &self.cloud.query.scope { + Scope::Bucket { .. } => Scope::Library, + s => s.clone(), + } + }; + value(remote::Rule { + scope, + text, + filters, + }) + }; + let mut params = vec![("name", get("cloud-name").into()), ("rule", rule)]; + let method = if kind == "edit-bucket" { + let (id, revision) = self + .cloud + .form_target + .clone() + .ok_or_else(|| anyhow!("No bucket selected"))?; + params.extend([("id", id.into()), ("revision", revision.into())]); + "bucket.update" + } else { + "bucket.create" + }; + self.cloud_mutate(method, params); + } + "rename-folder" => { + let (id, revision) = self + .cloud + .form_target + .clone() + .ok_or_else(|| anyhow!("No folder selected"))?; + self.cloud_mutate( + "folder.update", + vec![ + ("id", id.into()), + ("revision", revision.into()), + ("name", get("cloud-name").into()), + ], + ); + } + "delete-folder" | "delete-bucket" => { + let (id, revision) = self + .cloud + .form_target + .clone() + .ok_or_else(|| anyhow!("No item selected"))?; + let mut params = vec![("id", id.into()), ("revision", revision.into())]; + if kind == "delete-folder" && get("cloud-check-contents") == "1" { + params.push(("contents", true.into())); + } + self.cloud_mutate( + if kind == "delete-folder" { + "folder.delete" + } else { + "bucket.delete" + }, + params, + ); + } + "upload-document" => { + let folder = get("cloud-folder"); + self.cloud_upload_current((!folder.is_empty()).then_some(folder))?; + } + "upload-folder" => { + let path = PathBuf::from(get("cloud-path")); + anyhow::ensure!(path.is_dir(), "That folder is no longer there"); + let folder = get("cloud-folder"); + self.cloud_drop_local(None, (!folder.is_empty()).then_some(folder), vec![path], cx); + } + "move-items" => { + let (bucket, _) = self + .cloud + .form_target + .take() + .ok_or_else(|| anyhow!("No bucket selected"))?; + let folder = get("cloud-folder"); + self.cloud_mutate( + "asset.move", + vec![ + ( + "items", + Value::Array(vec![map([ + ("kind", "bucket".into()), + ("id", bucket.into()), + ])]), + ), + ( + "folder_id", + if folder.is_empty() { + Value::Nil + } else { + folder.into() + }, + ), + ], + ); + } + _ => return Err(anyhow!("Unknown cloud action")), + } + Ok(()) + } +} +/// Where a bucket's originals are staged for the batch dialog. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn batch_dir() -> PathBuf { + state_dir().join("batch") +} +/// A file or folder name the local disk will take: the cloud's names +/// are free text, and a slash in one must not become a path. +#[cfg(not(target_arch = "wasm32"))] +fn safe_component(name: &str) -> String { + let cleaned: String = name + .chars() + .map(|c| { + if matches!(c, '/' | '\\' | '\0') { + '_' + } else { + c + } + }) + .collect(); + match cleaned.trim() { + "" | "." | ".." => "untitled".to_string(), + s => s.to_string(), + } +} +/// The folder names from `root` (exclusive; `None` is the library) down +/// to `folder`, for recreating the cloud's tree on disk. A folder off +/// the catalogue page, or a cycle, stops the walk. +#[cfg(not(target_arch = "wasm32"))] +fn folder_path_below(folders: &[Folder], root: Option<&str>, folder: Option<&str>) -> Vec { + let mut names = Vec::new(); + let mut at = folder.map(str::to_string); + for _ in 0..32 { + let Some(id) = at else { break }; + if root == Some(id.as_str()) { + break; + } + let Some(f) = folders.iter().find(|f| f.id == id) else { + break; + }; + names.push(f.name.clone()); + at = f.parent_id.clone(); + } + names.reverse(); + names +} +/// The most located photos the world map plots for one scope. +#[cfg(not(target_arch = "wasm32"))] +const MAP_ASSET_CAP: usize = 5000; +/// Every asset in a scope, page by page through `assets.query`. +#[cfg(not(target_arch = "wasm32"))] +async fn scope_assets(handle: &remote::Handle, scope: &Scope) -> Result> { + let query = AssetQuery { + scope: scope.clone(), + text: String::new(), + filters: Filters::default(), + sort: "name".into(), + offset: 0, + limit: 500, + }; + query_assets(handle, query, usize::MAX).await +} +/// Every asset a query matches, page by page through `assets.query`, +/// up to `cap`. +#[cfg(not(target_arch = "wasm32"))] +async fn query_assets( + handle: &remote::Handle, + mut query: AssetQuery, + cap: usize, +) -> Result> { + let mut all = Vec::new(); + query.limit = 500; + loop { + let page: remote::Snapshot = parse( + handle + .request_async("assets.query", value(query.clone())) + .await?, + )?; + let got = page.items.len() as u64; + for item in page.items { + all.push(parse::(item)?); + } + query.offset += got; + if got == 0 || query.offset >= page.total || all.len() >= cap { + break; + } + } + Ok(all) +} +/// One batch of a drop: at most this many bytes of files, or this many +/// files, per compressed payload — several payloads for a big drop, +/// each small enough to retry on its own. +const BATCH_BYTES: usize = 48 * 1024 * 1024; +const BATCH_FILES: usize = 250; +struct UploadSummary { + uploaded: usize, + /// Left out because the library already held them. + existing: usize, + skipped: Vec, +} +impl UploadSummary { + fn message(&self) -> String { + let mut uploaded = match self.uploaded { + 0 => "Nothing uploaded".to_string(), + 1 => "Uploaded 1 photo".into(), + n => format!("Uploaded {n} photos"), + }; + match self.existing { + 0 => {} + 1 => uploaded.push_str("; 1 was already in Schist Cloud"), + n => uploaded.push_str(&format!("; {n} were already in Schist Cloud")), + } + match self.skipped.first() { + None => uploaded, + Some(reason) => format!( + "{uploaded}; skipped {} file{}: {reason}{}", + self.skipped.len(), + if self.skipped.len() == 1 { "" } else { "s" }, + if self.skipped.len() > 1 { + format!(" (and {} more)", self.skipped.len() - 1) + } else { + String::new() + }, + ), + } + } +} + +fn read_cloud_upload(path: &std::path::Path, relative: Option) -> Result { + #[cfg(not(target_arch = "wasm32"))] + let bytes = { + use std::io::Read as _; + let metadata = std::fs::metadata(path)?; + remote::validate_upload_size(metadata.len())?; + anyhow::ensure!( + metadata.len() <= remote::MAX_SINGLE_UPLOAD_BYTES, + "Use chunk uploads for files over 100 MiB" + ); + // Bound the read too, in case the source grows after checking its size. + let mut bytes = Vec::new(); + std::fs::File::open(path)? + .take(remote::MAX_SINGLE_UPLOAD_BYTES + 1) + .read_to_end(&mut bytes)?; + bytes + }; + #[cfg(target_arch = "wasm32")] + let bytes = { + let bytes = crate::web::read_file(path)?; + remote::validate_upload_size(bytes.len() as u64)?; + bytes.to_vec() + }; + remote::validate_upload_size(bytes.len() as u64)?; + let name = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + Ok(BatchFile { + source: path.to_path_buf(), + path: relative.unwrap_or_else(|| name.clone()), + name, + mime: mime(path), + digest: remote::transfer::sha256_hex(&bytes), + bytes, + }) +} + +struct BatchFile { + /// Where the bytes came from, to read them again for a repack or a + /// single upload. + source: PathBuf, + /// The relative path inside the drop (sub-folders become cloud + /// folders), or just the name. + path: String, + name: String, + mime: &'static str, + /// SHA-256, hex: what the provider deduplicates by. + digest: String, + bytes: Vec, +} +/// A batch member without its bytes: enough to ask the provider whether +/// it already has the file, and to read it again if it must go up on +/// its own. +struct BatchEntry { + source: PathBuf, + path: String, + digest: String, +} +impl From<&BatchFile> for BatchEntry { + fn from(file: &BatchFile) -> Self { + Self { + source: file.source.clone(), + path: file.path.clone(), + digest: file.digest.clone(), + } + } +} +/// Read a batch's members again, for a repack after deduplication or +/// the single-file fallback. +fn reread(entries: &[BatchEntry]) -> Result> { + entries + .iter() + .map(|e| read_cloud_upload(&e.source, Some(e.path.clone()))) + .collect() +} +/// The batch payload: magic, a MessagePack manifest, then the files +/// back to back, gzip-compressed. Returns the payload and the size of +/// the files inside it. +fn pack_batch(files: &[BatchFile]) -> Result<(Vec, u64)> { + use std::io::Write as _; + let manifest = remote::protocol::encode(&map([( + "files", + Value::Array( + files + .iter() + .map(|f| { + map([ + ("path", f.path.clone().into()), + ("mime_type", f.mime.into()), + ("size", (f.bytes.len() as u64).into()), + ]) + }) + .collect(), + ), + )]))?; + let total: usize = files.iter().map(|f| f.bytes.len()).sum(); + let mut raw = Vec::with_capacity(12 + manifest.len() + total); + raw.extend_from_slice(b"SCHISTB1"); + raw.extend_from_slice(&(manifest.len() as u32).to_be_bytes()); + raw.extend_from_slice(&manifest); + for file in files { + raw.extend_from_slice(&file.bytes); + } + // Photos hardly compress; the fast level keeps the CPU out of the + // way of the network without pretending otherwise. + let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::fast()); + encoder.write_all(&raw)?; + Ok((encoder.finish()?, total as u64)) +} +#[derive(serde::Deserialize)] +struct BatchTicket { + batch_id: String, + put_url: String, +} +#[derive(serde::Deserialize)] +struct BatchCommitted { + assets: Vec, +} +/// How many packed batches wait ahead of the upload: with the one +/// being packed and the one going up, at most four in memory. +#[cfg(not(target_arch = "wasm32"))] +const PREPARE_AHEAD: usize = 2; +/// The provider's batch support, as the uploader learns it and tells +/// the packer: unknown at first, then batches or singles. +const SUPPORT_UNKNOWN: u8 = 0; +const SUPPORT_BATCH: u8 = 1; +const SUPPORT_SINGLES: u8 = 2; +/// What the packer hands the uploader, in drop order. +enum Prepared { + /// A batch: its members, and the compressed payload unless the + /// provider is known to take singles only (an oversized single + /// file also comes without one). The bytes are not kept: a repack + /// or a single upload reads them from disk again. + Batch { + payload: Option<(Vec, u64)>, + entries: Vec, + }, + /// Too big for a batch: the resumable chunked path reads it itself. + #[cfg(not(target_arch = "wasm32"))] + Large { + path: PathBuf, + relative: Option, + size: u64, + }, + /// Left out, with the reason for the summary. + Skipped(String), + /// Packing failed; the upload stops here. + Failed(anyhow::Error), +} +/// Reads and packs the drop's files into batches, ahead of the +/// network. Runs on its own thread on desktop, inline in the browser. +struct Preparer { + files: std::vec::IntoIter<(PathBuf, Option)>, + batch: Vec, + batch_bytes: usize, + ready: std::collections::VecDeque, + support: Arc, +} +impl Preparer { + fn new( + files: Vec<(PathBuf, Option)>, + support: Arc, + ) -> Self { + Self { + files: files.into_iter(), + batch: Vec::new(), + batch_bytes: 0, + ready: std::collections::VecDeque::new(), + support, + } + } + /// The batch so far as one item, packed unless the provider is + /// known to take singles only; the raw files stay unless batches + /// are known to work. + fn flush(&mut self) -> Option { + if self.batch.is_empty() { + return None; + } + let files = std::mem::take(&mut self.batch); + self.batch_bytes = 0; + let support = self.support.load(std::sync::atomic::Ordering::Relaxed); + let payload = if support == SUPPORT_SINGLES { + None + } else { + match pack_batch(&files) { + Ok(payload) => Some(payload), + Err(e) => return Some(Prepared::Failed(e)), + } + }; + let entries = files.iter().map(BatchEntry::from).collect(); + Some(Prepared::Batch { payload, entries }) + } + fn next(&mut self) -> Option { + loop { + if let Some(item) = self.ready.pop_front() { + return Some(item); + } + let Some((path, relative)) = self.files.next() else { + return self.flush(); + }; + #[cfg(not(target_arch = "wasm32"))] + if let Ok(metadata) = std::fs::metadata(&path) { + if metadata.len() > remote::MAX_SINGLE_UPLOAD_BYTES + && metadata.len() <= remote::MAX_UPLOAD_BYTES + { + // Keep the drop's order: the batch so far goes first. + if let Some(batch) = self.flush() { + self.ready.push_back(batch); + } + self.ready.push_back(Prepared::Large { + path, + relative, + size: metadata.len(), + }); + continue; + } + } + let file = match read_cloud_upload(&path, relative) { + Ok(file) => file, + Err(error) => { + self.ready.push_back(Prepared::Skipped(format!( + "{}: {error}", + path.file_name().unwrap_or_default().to_string_lossy() + ))); + continue; + } + }; + if file.bytes.len() > BATCH_BYTES { + if let Some(batch) = self.flush() { + self.ready.push_back(batch); + } + self.ready.push_back(Prepared::Batch { + payload: None, + entries: vec![BatchEntry::from(&file)], + }); + continue; + } + if self.batch_bytes + file.bytes.len() > BATCH_BYTES || self.batch.len() >= BATCH_FILES + { + if let Some(batch) = self.flush() { + self.ready.push_back(batch); + } + } + self.batch_bytes += file.bytes.len(); + self.batch.push(file); + } + } +} +/// How long a transfer keeps waiting for the connection to come back: +/// this many rounds of at most half a minute each — about four hours. +const OFFLINE_RETRIES: u32 = 480; +/// Sends prepared items up one at a time and keeps the count. +struct Uploader { + handle: remote::Handle, + folder: Option, + support: Arc, + sender: mpsc::Sender, + epoch: u64, + total: u64, + done: u64, + uploaded: Vec, + /// Assets the library already had, found by digest: left out of the + /// upload, still added to a bucket drop. + existing: Vec, + skipped: Vec, + /// Whether the provider answers `assets.exists`, learned from the + /// first reply. + dedupe: u8, +} +impl Uploader { + fn report(&self, label: String) { + let _ = self.sender.send(Job::Progress { + epoch: self.epoch, + done: self.done, + total: self.total, + label, + }); + } + /// Run one network step, and when the connection is what failed, + /// wait for it to return and run the step again — with the same + /// mutation IDs, so the provider answers a repeated commit from its + /// record rather than doing it twice. A refused request (quota, a + /// bad file, an expired ticket) is an answer and comes straight back. + async fn retrying(&self, what: &str, step: impl Fn() -> Fut) -> Result + where + Fut: std::future::Future>, + { + let mut attempt = 0u32; + loop { + match step().await { + Ok(value) => return Ok(value), + Err(error) if remote::transport::transient(&error) && attempt < OFFLINE_RETRIES => { + attempt += 1; + self.report(format!( + "Connection interrupted while {what} — {} of {} uploaded; waiting…", + self.done, self.total + )); + // The first retry is immediate: the session renews + // itself every quarter hour by reconnecting, and that + // is over in a moment. Only a repeat failure pauses, + // for a gateway that just dropped us and is not ready. + if attempt > 1 { + let pause = (1u64 << attempt.min(5)).min(30); + remote::runtime::sleep(std::time::Duration::from_secs(pause)).await; + } + if !self.handle.wait_online().await { + return Err(error.context("The cloud connection was closed")); + } + self.report(format!( + "Reconnected — resuming {what} ({} of {} uploaded)…", + self.done, self.total + )); + } + Err(error) => return Err(error), + } + } + } + fn step(&mut self, by: usize) { + self.done += by as u64; + let (done, total) = (self.done, self.total); + self.report(if self.skipped.is_empty() { + format!("Uploading {done} of {total} photos…") + } else { + format!( + "Uploading {done} of {total} photos ({} skipped)…", + self.skipped.len() + ) + }); + } + async fn take(&mut self, item: Prepared) -> Result<()> { + match item { + Prepared::Batch { + mut payload, + mut entries, + } => { + let count = entries.len(); + // Ask first what the library already holds: those files + // stay home, and the payload is packed again without + // them. A provider without the question uploads all. + if self.dedupe != SUPPORT_SINGLES && !entries.is_empty() { + let digests: Vec = entries.iter().map(|e| e.digest.clone()).collect(); + match self + .retrying("checking for duplicates", || { + existing_assets(&self.handle, &digests) + }) + .await? + { + Some(found) => { + self.dedupe = SUPPORT_BATCH; + let before = entries.len(); + let existing = &mut self.existing; + entries.retain(|e| match found.get(&e.digest) { + Some(id) => { + existing.push(id.clone()); + false + } + None => true, + }); + if entries.len() != before { + payload = None; + } + } + None => self.dedupe = SUPPORT_SINGLES, + } + } + if entries.is_empty() { + self.step(count); + return Ok(()); + } + let folder = self.folder.clone(); + let mut ids = None; + if self.support.load(std::sync::atomic::Ordering::Relaxed) != SUPPORT_SINGLES { + let (payload, total) = match payload.take() { + Some(packed) => packed, + None => pack_batch(&reread(&entries)?)?, + }; + { + // One set of IDs for the batch: every retry + // re-sends the same mutations, so the provider + // can answer a repeat from its record. + let batch = BatchIds::new(); + match self + .retrying("uploading a batch", || { + send_batch( + &self.handle, + folder.as_deref(), + &payload, + total, + count, + &batch, + ) + }) + .await? + { + Some(found) => { + self.support + .store(SUPPORT_BATCH, std::sync::atomic::Ordering::Relaxed); + ids = Some(found); + } + None => self + .support + .store(SUPPORT_SINGLES, std::sync::atomic::Ordering::Relaxed), + } + } + } + match ids { + Some(ids) => self.uploaded.extend(ids), + None => { + let files = reread(&entries)?; + for file in &files { + let mutation = remote::Uuid::new_v4().to_string(); + let id = self + .retrying("uploading a photo", || { + send_single(&self.handle, folder.as_deref(), file, &mutation) + }) + .await?; + self.uploaded.push(id); + } + } + } + self.step(count); + } + #[cfg(not(target_arch = "wasm32"))] + Prepared::Large { + path, + relative, + size, + } => { + let name = path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(); + self.report(format!("Checking {name} for a resumable upload…")); + let (sender, epoch, done, total) = + (self.sender.clone(), self.epoch, self.done, self.total); + // The chunked upload resumes from the parts already + // stored, so a retry after an outage picks up where it + // stopped. + let asset = self + .retrying("uploading a large file", || { + let (sender, name) = (sender.clone(), name.clone()); + self.handle.upload_path_async( + &path, + mime(&path), + self.folder.as_deref(), + relative.as_deref(), + move |bytes| { + let _ = sender.send(Job::Progress { + epoch, + done, + total, + label: format!( + "Uploading {name}: {}% ({} / {} MiB)", + bytes * 100 / size.max(1), + bytes / 1024 / 1024, + size / 1024 / 1024 + ), + }); + }, + ) + }) + .await?; + self.uploaded.push(asset.id); + self.step(1); + } + Prepared::Skipped(reason) => { + self.skipped.push(reason); + self.step(1); + } + Prepared::Failed(error) => return Err(error), + } + Ok(()) + } +} +#[derive(serde::Deserialize)] +struct ExistingAsset { + sha256: String, + id: String, +} +#[derive(serde::Deserialize)] +struct ExistingAssets { + found: Vec, +} +/// Which of these digests the library already holds, as digest → asset +/// ID. `None` when the provider cannot say, so everything uploads. +async fn existing_assets( + handle: &remote::Handle, + digests: &[String], +) -> Result>> { + let params = map([( + "sha256", + Value::Array(digests.iter().map(|d| d.clone().into()).collect()), + )]); + match handle.request_async("assets.exists", params).await { + Ok(reply) => { + let found: ExistingAssets = parse(reply)?; + Ok(Some( + found.found.into_iter().map(|f| (f.sha256, f.id)).collect(), + )) + } + Err(e) if e.to_string().contains("method_not_found") => Ok(None), + Err(e) => Err(e), + } +} +/// The mutation IDs one batch uses, fixed for its lifetime so retries +/// repeat rather than duplicate. +struct BatchIds { + prepare: String, + commit: String, +} +impl BatchIds { + fn new() -> Self { + Self { + prepare: remote::Uuid::new_v4().to_string(), + commit: remote::Uuid::new_v4().to_string(), + } + } +} +/// Upload one packed batch: prepare, put the payload, commit. `None` +/// when the provider has no batch method, so the caller falls back to +/// singles. Repeating the call after an outage repeats the same +/// mutations: a prepare already answered returns its ticket, a commit +/// already done returns its assets. +async fn send_batch( + handle: &remote::Handle, + folder: Option<&str>, + payload: &[u8], + total: u64, + count: usize, + ids: &BatchIds, +) -> Result>> { + let mut fields = vec![ + ("size", (payload.len() as u64).into()), + ("total", total.into()), + ("count", (count as u64).into()), + ("mutation_id", ids.prepare.clone().into()), + ]; + if let Some(folder) = folder { + fields.push(("folder_id", folder.into())); + } + let ticket: BatchTicket = match handle + .request_async("asset.prepare_batch", map(fields)) + .await + { + Ok(reply) => parse(reply)?, + Err(e) if e.to_string().contains("method_not_found") => return Ok(None), + Err(e) => return Err(e), + }; + remote::auth::upload_async(&ticket.put_url, "application/gzip", payload).await?; + let committed: BatchCommitted = parse( + handle + .request_async( + "asset.commit_batch", + map([ + ("batch_id", ticket.batch_id.into()), + ("mutation_id", ids.commit.clone().into()), + ]), + ) + .await?, + )?; + Ok(Some(committed.assets)) +} +/// The one-file path: a provider without batches, or a file too big for +/// one. +async fn send_single( + handle: &remote::Handle, + folder: Option<&str>, + file: &BatchFile, + mutation: &str, +) -> Result { + let relative = (file.path != file.name).then_some(file.path.as_str()); + let asset = handle + .upload_async(remote::Upload { + name: &file.name, + bytes: &file.bytes, + mime: file.mime, + folder, + asset: None, + relative, + mutation, + }) + .await?; + Ok(asset.id) +} +fn mime(path: &std::path::Path) -> &'static str { + match path + .extension() + .and_then(|s| s.to_str()) + .unwrap_or("") + .to_ascii_lowercase() + .as_str() + { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "webp" => "image/webp", + "tif" | "tiff" => "image/tiff", + "psd" | "psb" => "image/vnd.adobe.photoshop", + _ => "application/octet-stream", + } +} +fn enumerate_files( + root: &std::path::Path, + path: &std::path::Path, + out: &mut Vec<(PathBuf, Option)>, +) -> Result<()> { + for entry in std::fs::read_dir(path)? { + let entry = entry?; + let ty = entry.file_type()?; + if ty.is_symlink() { + continue; + } + // Hidden entries stay home: the gallery's own `.schist` sidecars + // and version folders, `.DS_Store`, thumbnail caches — none of + // them are photos anyone meant to upload. + if entry.file_name().to_string_lossy().starts_with('.') { + continue; + } + if ty.is_dir() { + enumerate_files(root, &entry.path(), out)?; + } else if ty.is_file() { + let path = entry.path(); + let relative = path + .strip_prefix(root)? + .to_string_lossy() + .replace('\\', "/"); + out.push((path, Some(relative))); + } + } + Ok(()) +} + +fn parse_filters(fields: &[(&'static str, String, String)]) -> Result { + let get = |key: &str| { + fields + .iter() + .find(|(k, _, _)| *k == key) + .map(|(_, _, v)| v.trim()) + .unwrap_or("") + }; + let list = |key: &str| { + let s = get(key); + (!s.is_empty()).then(|| { + s.split(',') + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + .collect() + }) + }; + let edited = match get("cloud-edited") { + "any" | "" => None, + "yes" => Some(true), + "no" => Some(false), + _ => return Err(anyhow!("Edited must be any, yes or no")), + }; + let content = match get("cloud-content") { + "all" | "" => None, + "safe" => Some("safe".into()), + "flagged" => Some("flagged".into()), + _ => return Err(anyhow!("Content must be all, safe or flagged")), + }; + let rating = get("cloud-rating"); + let min_rating = if rating.is_empty() { + None + } else { + let r: u8 = rating.parse()?; + anyhow::ensure!(r <= 5, "Rating must be between 0 and 5"); + Some(r) + }; + let b = get("cloud-bounds"); + let bounds = if b.is_empty() { + None + } else { + let c = b + .split(',') + .map(|s| s.trim().parse::()) + .collect::, _>>()?; + anyhow::ensure!( + c.len() == 4 + && c.iter().all(|v| v.is_finite()) + && c[0] >= -90.0 + && c[2] <= 90.0 + && c[0] <= c[2] + && c[1].abs() <= 180.0 + && c[3].abs() <= 180.0, + "Enter valid south, west, north, east coordinates" + ); + Some(remote::Bounds { + south: c[0], + west: c[1], + north: c[2], + east: c[3], + }) + }; + let captured_after = remote::parse_date(get("cloud-after"), false)?; + let captured_before = remote::parse_date(get("cloud-before"), true)?; + if let (Some(a), Some(b)) = (captured_after, captured_before) { + anyhow::ensure!(a <= b, "End date must follow start date"); + } + Ok(Filters { + person_id: None, + mime_types: list("cloud-types"), + tags: list("cloud-tags"), + edited, + content, + min_rating, + bounds, + captured_after, + captured_before, + }) +} + +pub(super) fn rgba_to_render_image( + width: u32, + height: u32, + mut rgba: Vec, +) -> Option> { + for pixel in rgba.as_chunks_mut::<4>().0 { + pixel.swap(0, 2); + } + let buffer = image::RgbaImage::from_raw(width, height, rgba)?; + Some(Arc::new(RenderImage::new(smallvec![image::Frame::new( + buffer + )]))) +} + +#[cfg(test)] +mod cloud_lifecycle_tests { + use super::*; + #[cfg(not(target_arch = "wasm32"))] + #[test] + fn oversized_and_empty_files_do_not_discard_the_valid_batch() { + use std::io::Read as _; + let root = std::env::temp_dir().join(format!("schist-upload-{}", remote::Uuid::new_v4())); + std::fs::create_dir(&root).unwrap(); + std::fs::write(root.join("before.jpg"), b"before").unwrap(); + std::fs::File::create(root.join("large.mov")) + .unwrap() + .set_len(remote::MAX_UPLOAD_BYTES + 1) + .unwrap(); + std::fs::write(root.join("empty.jpg"), b"").unwrap(); + std::fs::write(root.join("after.jpg"), b"after").unwrap(); + let mut batch = Vec::new(); + let mut skipped = Vec::new(); + for name in ["before.jpg", "large.mov", "empty.jpg", "after.jpg"] { + match read_cloud_upload(&root.join(name), Some(format!("trip/{name}"))) { + Ok(file) => batch.push(file), + Err(error) => skipped.push(format!("{name}: {error}")), + } + } + assert_eq!(batch.len(), 2); + assert_eq!(batch[0].path, "trip/before.jpg"); + assert_eq!(batch[1].path, "trip/after.jpg"); + let (payload, total) = pack_batch(&batch).unwrap(); + assert_eq!(total, 11); + let mut raw = Vec::new(); + flate2::read::GzDecoder::new(payload.as_slice()) + .read_to_end(&mut raw) + .unwrap(); + let length = u32::from_be_bytes(raw[8..12].try_into().unwrap()) as usize; + assert_eq!(&raw[12 + length..], b"beforeafter"); + let message = UploadSummary { + uploaded: batch.len(), + existing: 0, + skipped: skipped.clone(), + } + .message(); + assert!(message.contains("Uploaded 2 photos; skipped 2 files")); + let deduped = UploadSummary { + uploaded: batch.len(), + existing: 3, + skipped, + } + .message(); + assert!(deduped.contains("Uploaded 2 photos; 3 were already in Schist Cloud; skipped 2")); + assert!(message.contains("large.mov")); + assert!(message.contains("5 GiB")); + std::fs::remove_dir_all(root).unwrap(); + } + fn binding(asset: &str) -> RemoteDocument { + let mut source = Document::new("Original", 1, 1, schist_color::Depth::Eight); + let mut shared = remote::document::SharedDocument::new(&source).unwrap(); + source.title = "Unsynced local edit".into(); + shared.local_changes(&source).unwrap(); + RemoteDocument { + asset: Asset { + faces: Vec::new(), + moderation: None, + id: asset.into(), + folder_id: None, + name: "Original".into(), + mime_type: "image/png".into(), + revision: 1, + size: 1, + edited: false, + tags: vec![], + rating: 0, + captured_at: None, + modified_at: 0, + thumbnail_url: None, + place_name: None, + location: None, + }, + shared, + joined: true, + detached: false, + vector: vec![0], + sending: true, + changed: true, + generation: 1, + saved: 0, + render: true, + } + } + #[test] + fn terminated_document_keeps_edits_ignores_late_events_and_requires_explicit_reopen() { + let mut state = CloudState::default(); + let id = DocumentId(41); + let other = DocumentId(42); + state.docs.insert(id, binding("revoked")); + state.docs.insert(other, binding("other")); + let before = state.docs[&id].shared.full_state(); + state.pending.insert("late-join".into(), Pending::Join(id)); + state.pending.insert( + "late-update".into(), + Pending::Update { + doc: id, + vector: vec![0], + generation: 1, + }, + ); + state + .pending + .insert("other-join".into(), Pending::Join(other)); + assert_eq!(state.detach_document("revoked"), Some(id)); + assert!(!state.pending.contains_key("late-join")); + assert!(!state.pending.contains_key("late-update")); + assert!(state.pending.contains_key("other-join")); + // Even a malformed late push is discarded before touching the detached CRDT. + state.apply_document_update("revoked", &[255]).unwrap(); + state.disconnect(); + assert_eq!(state.joinable_documents(), vec![other]); + let document = &state.docs[&id]; + assert_eq!(document.shared.full_state(), before); + assert_eq!((document.generation, document.saved), (1, 0)); + assert!(!document.joined && !document.sending && !document.render); + assert!(state.reopen_document(id)); + assert!(!state.reopen_document(id)); + assert!(state.joinable_documents().contains(&id)); + assert!(state.apply_document_update("revoked", &[255]).is_err()); + } +} diff --git a/crates/app/src/workspace/cloud_generation.rs b/crates/app/src/workspace/cloud_generation.rs new file mode 100644 index 00000000..61b648e5 --- /dev/null +++ b/crates/app/src/workspace/cloud_generation.rs @@ -0,0 +1,471 @@ +//! Dynamic generation form, live text previews and streamed image slots. +use super::*; +use crate::ui; +use gpui::{img, StatefulInteractiveElement as _, StyledImage as _}; +use schist_cloud::generation::{self as api, Input, Inputs, Item}; +use std::{ + collections::HashMap, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc, + }, + time::Duration, +}; +use web_time::Instant; + +pub(crate) struct Generated { + index: usize, + bytes: Vec, + image: Option>, +} +enum Job { + Form(u64, std::result::Result, String>), + Preview(u64, u64, String, std::result::Result), + Event(u64, api::Event), + Image(u64, Generated), + Done(u64, std::result::Result<(), String>), +} +pub(crate) struct GenerationState { + items: Vec, + pub values: Inputs, + pub editing: Option, + previews: HashMap, + results: Vec, + slots: Vec<(String, bool, Option)>, + pub cancel: Arc, + jobs: mpsc::Receiver, + sender: mpsc::Sender, + loading: bool, + running: bool, + error: String, + last_values: Option, + preview_due: Option, + seq: u64, + total_bytes: usize, +} +impl Default for GenerationState { + fn default() -> Self { + let (sender, jobs) = mpsc::channel(); + Self { + items: vec![], + values: Inputs::new(), + editing: None, + previews: HashMap::new(), + results: vec![], + slots: vec![], + cancel: Arc::new(AtomicBool::new(false)), + jobs, + sender, + loading: false, + running: false, + error: String::new(), + last_values: None, + preview_due: None, + seq: 0, + total_bytes: 0, + } + } +} +impl Drop for GenerationState { + fn drop(&mut self) { + self.cancel.store(true, Ordering::Relaxed); + } +} +impl Workspace { + pub(crate) fn cloud_generate_open(&mut self, cx: &mut Context) { + let Some(account) = self.cloud.account.clone() else { + self.cloud_sign_in(cx); + return; + }; + self.open_modal(Modal::CloudGenerate, cx); + let g = &mut self.cloud.generation; + if !g.items.is_empty() || g.loading { + return; + } + g.loading = true; + let sender = g.sender.clone(); + let epoch = self.cloud.epoch; + schist_cloud::runtime::spawn(async move { + let result = api::form(&account).await.map_err(|e| e.to_string()); + let _ = sender.send(Job::Form(epoch, result)); + }); + } + pub(crate) fn cloud_generation_tick(&mut self, cx: &mut Context) { + let g = &mut self.cloud.generation; + let mut changed = false; + while let Ok(job) = g.jobs.try_recv() { + changed = true; + match job { + Job::Form(epoch, result) if epoch == self.cloud.epoch => { + g.loading = false; + match result { + Ok(items) => g.items = items, + Err(e) => g.error = e, + } + } + Job::Preview(epoch, seq, url, result) + if epoch == self.cloud.epoch && seq == g.seq => + { + g.previews.insert( + url, + result.unwrap_or_else(|e| format!("Preview unavailable: {e}")), + ); + } + Job::Done(epoch, result) if epoch == self.cloud.epoch => { + g.running = false; + if let Err(e) = result { + g.error = e; + } + } + Job::Event(epoch, event) if epoch == self.cloud.epoch => match event { + api::Event::Layout(parts) => { + g.slots.clear(); + for part in parts { + for i in 0..part.children_count { + g.slots.push(( + format!("{} · {}", part.part_name, i + 1), + false, + None, + )); + } + } + } + api::Event::Complete { index, rejected } => { + if let Some(slot) = g.slots.get_mut(index) { + slot.1 = true; + slot.2 = rejected; + } + } + api::Event::Image { .. } => unreachable!("image decoded by worker"), + }, + Job::Image(epoch, generated) if epoch == self.cloud.epoch => { + g.total_bytes += generated.bytes.len(); + g.results.push(generated); + } + _ => {} + } + } + if matches!(self.modal, Some(Modal::CloudGenerate)) && !g.loading { + if g.last_values.as_ref() != Some(&g.values) { + g.last_values = Some(g.values.clone()); + g.seq += 1; + g.preview_due = Some(Instant::now() + Duration::from_millis(300)); + } + if g.preview_due.is_some_and(|due| Instant::now() >= due) { + g.preview_due = None; + if let Some(account) = self.cloud.account.clone() { + let urls: Vec<_> = g + .items + .iter() + .filter_map(|item| { + if let Item::LiveTextPreview { live_preview_url } = item { + Some(live_preview_url.clone()) + } else { + None + } + }) + .collect(); + let inputs = g.values.clone(); + let seq = g.seq; + let epoch = self.cloud.epoch; + let sender = g.sender.clone(); + schist_cloud::runtime::spawn(async move { + for url in urls { + let result = api::preview(&account, &url, &inputs) + .await + .map_err(|e| e.to_string()); + let _ = sender.send(Job::Preview(epoch, seq, url, result)); + } + }); + } + } + } + if changed { + cx.notify(); + } + } + fn cloud_generate_run(&mut self, cx: &mut Context) { + self.commit_focused_field(); + let Some(account) = self.cloud.account.clone() else { + return; + }; + let g = &mut self.cloud.generation; + if g.running { + return; + } + if let Err(e) = api::validate(&g.items, &g.values) { + g.error = e.to_string(); + cx.notify(); + return; + } + g.cancel = Arc::new(AtomicBool::new(false)); + g.running = true; + g.error.clear(); + g.results.clear(); + g.slots.clear(); + g.total_bytes = 0; + let inputs = g.values.clone(); + let cancel = g.cancel.clone(); + let sender = g.sender.clone(); + let epoch = self.cloud.epoch; + schist_cloud::runtime::spawn(async move { + let mut retained = 0usize; + let result = api::generate(&account, &inputs, &cancel, |event| { + if let api::Event::Image { index, bytes } = event { + retained += bytes.len(); + if retained > 256 * 1024 * 1024 { + cancel.store(true, Ordering::Relaxed); + return; + } + let image = (|| -> anyhow::Result<_> { + let mut reader = image::ImageReader::new(std::io::Cursor::new(&bytes)) + .with_guessed_format()?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(16384); + limits.max_image_height = Some(16384); + limits.max_alloc = Some(256 * 1024 * 1024); + reader.limits(limits); + let i = reader.decode()?.thumbnail(180, 140).to_rgba8(); + Ok(super::cloud::rgba_to_render_image( + i.width(), + i.height(), + i.into_raw(), + )) + })() + .ok() + .flatten(); + let _ = sender.send(Job::Image( + epoch, + Generated { + index, + bytes, + image, + }, + )); + } else { + let _ = sender.send(Job::Event(epoch, event)); + } + }) + .await + .map_err(|e| e.to_string()) + .and_then(|()| { + if retained > 256 * 1024 * 1024 { + Err("Generated results exceed the memory limit".into()) + } else { + Ok(()) + } + }); + let _ = sender.send(Job::Done(epoch, result)); + }); + cx.notify(); + } + fn cloud_open_generated(&mut self, index: usize, cx: &mut Context) { + let Some(generated) = self.cloud.generation.results.get(index) else { + return; + }; + let result = self + .registry + .codecs() + .find(|c| c.probe(&generated.bytes)) + .ok_or_else(|| anyhow::anyhow!("Unsupported generated image format")) + .and_then(|codec| codec.import(&generated.bytes)); + match result { + Ok(mut doc) => { + doc.title = format!("Generated {}", generated.index + 1); + doc.path = None; + doc.dirty = true; + self.open_in_tab(doc, true); + self.cloud_set_visible(false); + self.close_modal(cx); + } + Err(e) => { + self.cloud.generation.error = e.to_string(); + cx.notify(); + } + } + } +} +pub(crate) fn dialog(ws: &mut Workspace, cx: &mut Context) -> gpui::AnyElement { + let mut body = div() + .id("cloud-generation-form") + .flex() + .flex_col() + .gap_2() + .max_h(px(600.0)) + .overflow_y_scroll(); + let g = &ws.cloud.generation; + if g.loading { + body = body.child("Loading generation form…"); + } + if !g.error.is_empty() { + body = body.child(div().text_color(gpui::rgb(0xd45b50)).child(g.error.clone())); + } + for item in g.items.clone() { + match item { + Item::Text { + id, + title, + description, + required, + } => { + let current = match g.values.get(&id) { + Some(Input::Text(s)) => s.clone(), + _ => String::new(), + }; + let focused = ws.focused_field == Some("cloud-generation-input") + && g.editing.as_ref() == Some(&id); + let shown = if focused { + ws.field_buffer.clone() + } else { + current.clone() + }; + let control = div() + .min_h(px(25.0)) + .w(px(330.0)) + .px_1() + .bg(gpui::rgb(ui::palette().field_bg)) + .border_1() + .border_color(gpui::rgb(if focused { + ui::palette().accent + } else { + ui::palette().field_bg + })) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |ws, _, _, cx| { + ws.commit_focused_field(); + ws.cloud.generation.editing = Some(id.clone()); + ws.focus_field("cloud-generation-input", current.clone()); + cx.notify(); + }), + ) + .child(if focused { + let at = ws.field_cursor.min(shown.len()); + ui::caret_run( + shown[..at].into(), + shown[at..].into(), + ws.caret_on(), + ui::palette().text, + ) + .into_any_element() + } else { + div().child(shown).into_any_element() + }); + body = body + .child(ui::field_row( + format!("{title}{}", if required { " *" } else { "" }), + control, + )) + .child(div().text_size(px(11.0)).child(description)); + } + Item::Select { + id, + title, + description, + required, + multiple, + values, + } => { + let selected: Vec = match g.values.get(&id) { + Some(Input::Text(s)) => vec![s.clone()], + Some(Input::Multiple(v)) => v.clone(), + _ => vec![], + }; + let mut choices = div().flex().flex_wrap().gap_1(); + for choice in values { + let field = id.clone(); + let option = choice.id.clone(); + let chosen = selected.contains(&option); + choices = choices.child(ui::button( + format!("{} {}", if chosen { "●" } else { "○" }, choice.text), + false, + move |ws, _, cx| { + let values = &mut ws.cloud.generation.values; + if multiple { + let mut selected = match values.get(&field) { + Some(Input::Multiple(v)) => v.clone(), + _ => vec![], + }; + if selected.contains(&option) { + selected.retain(|s| s != &option); + } else { + selected.push(option.clone()); + } + values.insert(field.clone(), Input::Multiple(selected)); + } else if chosen && !required { + values.remove(&field); + } else { + values.insert(field.clone(), Input::Text(option.clone())); + } + cx.notify(); + }, + cx, + )); + } + body = body + .child(ui::field_row( + format!("{title}{}", if required { " *" } else { "" }), + choices, + )) + .child(div().text_size(px(11.0)).child(description)); + } + Item::LiveTextPreview { live_preview_url } => { + body = body.child( + div().text_size(px(12.0)).child( + g.previews + .get(&live_preview_url) + .cloned() + .unwrap_or_else(|| "Preparing preview…".into()), + ), + ) + } + } + } + for (name, complete, rejected) in &g.slots { + body = body.child(div().text_size(px(11.0)).child(format!( + "{name}: {}", + rejected.as_deref().unwrap_or(if *complete { + "Complete" + } else { + "Generating…" + }) + ))); + } + let mut results = div().flex().flex_wrap().gap_2(); + for (i, result) in g.results.iter().enumerate() { + let mut card = div().flex().flex_col().gap_1(); + if let Some(image) = &result.image { + card = card.child( + img(image.clone()) + .w(px(180.0)) + .h(px(140.0)) + .object_fit(gpui::ObjectFit::Contain), + ); + } + results = results.child(card.child(ui::button( + "Open image", + false, + move |ws, _, cx| ws.cloud_open_generated(i, cx), + cx, + ))); + } + body = body.child(results); + let running = g.running; + let actions = div() + .flex() + .gap_2() + .child(ui::button( + if running { "Stop" } else { "Close" }, + false, + |ws, _, cx| { + ws.cloud.generation.cancel.store(true, Ordering::Relaxed); + ws.close_modal(cx); + }, + cx, + )) + .children( + (!running && !g.loading) + .then(|| ui::button("Generate", true, |ws, _, cx| ws.cloud_generate_run(cx), cx)), + ); + ui::modal_frame("Generate with Schist Cloud", 680.0, body, actions).into_any_element() +} diff --git a/crates/app/src/workspace/cloud_people.rs b/crates/app/src/workspace/cloud_people.rs new file mode 100644 index 00000000..92b5d3cd --- /dev/null +++ b/crates/app/src/workspace/cloud_people.rs @@ -0,0 +1,494 @@ +//! People names and boxes belong to the provider workspace; model credentials never leave it. +use super::cloud_view::CLOUD_GLYPH; +use super::gallery_chrome::{self as chrome, pal}; +use super::*; +use gpui::{img, StatefulInteractiveElement as _}; +use schist_cloud::{protocol::value, Face, FaceRect, Value}; + +/// The cloud's people, drawn like the local PEOPLE rows: a round badge, +/// the name, a count, and the actions on the right-click menu. Signed +/// out, or before the provider has looked, there is nothing to list. +/// `caption` adds the section heading, for a sidebar without a local +/// list above. +pub(crate) fn rows( + ws: &mut Workspace, + caption: bool, + cx: &mut Context, +) -> Vec { + if ws.cloud.account.is_none() { + return vec![]; + } + let Some(people) = ws.cloud.people.clone() else { + return vec![]; + }; + let mut rows: Vec = Vec::new(); + if caption { + rows.push(chrome::sidebar_caption("PEOPLE").into_any_element()); + } + let viewing = ws.cloud.query.filters.person_id.clone(); + let badge = |glyph: &'static str| { + div() + .w(px(20.0)) + .h(px(20.0)) + .flex_none() + .rounded_full() + .border_1() + .border_color(gpui::rgb(pal().text_dim)) + .flex() + .items_center() + .justify_center() + .text_size(px(11.0)) + .text_color(gpui::rgb(pal().text_dim)) + .child(glyph) + }; + let person_row = |id: SharedString, + glyph: &'static str, + name: String, + count: u64, + selected: bool, + filter: String, + context: Option, + cx: &mut Context| { + let mut row = div() + .id(id) + .flex() + .flex_row() + .items_center() + .gap_2() + .px_2() + .h(px(26.0)) + .text_size(px(12.0)) + .cursor_pointer() + .bg(gpui::rgb(if selected { + pal().sidebar_selected + } else { + pal().chrome_bg + })) + .hover(|s| s.bg(gpui::rgb(pal().sidebar_selected))) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |ws, _e: &MouseDownEvent, _w, cx| { + ws.cloud_person(Some(filter.clone()), cx) + }), + ); + if let Some(person) = context { + row = row.on_mouse_down( + MouseButton::Right, + cx.listener(move |ws, ev: &MouseDownEvent, _w, cx| { + ws.cloud.context = Some(( + ev.position, + super::cloud::CloudContext::Person(person.clone()), + )); + #[cfg(not(target_arch = "wasm32"))] + { + ws.library.context = None; + } + cx.notify(); + }), + ); + } + row.child(badge(glyph)) + .child(div().flex_grow().truncate().child(SharedString::from(name))) + .child( + div() + .text_size(px(10.0)) + .text_color(gpui::rgb(pal().text_dim)) + .child(format!("{count}")), + ) + .into_any_element() + }; + for person in &people.people { + rows.push(person_row( + SharedString::from(format!("cloud-person-{}", person.id)), + CLOUD_GLYPH, + person.name.clone(), + person.asset_count, + viewing.as_deref() == Some(&person.id), + person.id.clone(), + Some(person.id.clone()), + cx, + )); + } + if people.unnamed > 0 { + rows.push(person_row( + "cloud-person-unnamed".into(), + "?", + "Unnamed faces in Schist Cloud".into(), + people.unnamed, + viewing.as_deref() == Some("unnamed"), + "unnamed".into(), + None, + cx, + )); + } + if people.pending > 0 { + rows.push( + div() + .px_2() + .py_1() + .text_size(px(11.0)) + .text_color(gpui::rgb(pal().text_dim)) + .child(format!( + "Finding faces in {} cloud photos\u{2026}", + people.pending + )) + .into_any_element(), + ); + } + if people.failed > 0 { + rows.push( + div() + .px_2() + .py_1() + .text_size(px(11.0)) + .text_color(gpui::rgb(pal().text_dim)) + .child(format!("Could not process {} cloud photos", people.failed)) + .into_any_element(), + ); + } + rows +} +impl Workspace { + pub(crate) fn cloud_person(&mut self, person: Option, cx: &mut Context) { + self.cloud.query.filters.person_id = if self.cloud.query.filters.person_id == person { + None + } else { + person + }; + self.cloud.query.offset = 0; + self.cloud_watch_assets(false); + cx.notify(); + } + pub(crate) fn cloud_people_view(&mut self, cx: &mut Context) { + if let Some(asset) = self.cloud_lead_asset() { + self.cloud.face_drawing = false; + self.cloud.face_draft = None; + self.open_modal( + Modal::Cloud { + kind: "people-view", + fields: vec![("cloud-asset-id", "".into(), asset.id)], + }, + cx, + ); + } + } + fn cloud_face_name( + &mut self, + asset: &schist_cloud::Asset, + face: Option<&Face>, + rect: Option, + cx: &mut Context, + ) { + let name = face + .and_then(|f| f.person_id.as_ref()) + .and_then(|id| { + self.cloud + .people + .as_ref()? + .people + .iter() + .find(|p| &p.id == id) + }) + .map(|p| p.name.clone()) + .unwrap_or_default(); + self.open_modal( + Modal::Cloud { + kind: if face.is_some() { + "face-name" + } else { + "face-add" + }, + fields: vec![ + ("cloud-asset-id", "".into(), asset.id.clone()), + ("cloud-revision", "".into(), asset.revision.to_string()), + ( + "cloud-face-id", + "".into(), + face.map(|f| f.id.clone()).unwrap_or_default(), + ), + ( + "cloud-face-rect", + "".into(), + rect.map(|r| serde_json::to_string(&r).unwrap()) + .unwrap_or_default(), + ), + ("cloud-name", "Name".into(), name.clone()), + ], + }, + cx, + ); + self.focus_field("cloud-name", name); + } +} +pub(crate) fn viewer( + ws: &mut Workspace, + fields: Vec<(&'static str, String, String)>, + cx: &mut Context, +) -> gpui::AnyElement { + let id = fields + .iter() + .find(|(k, _, _)| *k == "cloud-asset-id") + .map(|(_, _, v)| v); + let asset = id + .and_then(|id| ws.cloud.assets.iter().find(|a| &a.id == id)) + .cloned(); + let mut body = div().flex().flex_col().gap_2(); + if let Some(asset) = asset { + if let Some((_, image)) = ws.cloud.thumbnails.get(&asset.id).cloned() { + let size = image.size(0); + let ratio = u32::from(size.width) as f32 / u32::from(size.height) as f32; + let width = 520.0_f32.min(380.0 * ratio); + let height = width / ratio; + let drawing = ws.cloud.face_drawing; + let up = asset.clone(); + let probe = cx.entity(); + let mut frame = div() + .relative() + .w(px(width)) + .h(px(height)) + .child(img(image).w(px(width)).h(px(height))) + .child( + canvas( + move |bounds, _, cx| { + probe.update(cx, |ws, _| ws.cloud.face_bounds = bounds) + }, + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |ws, e: &MouseDownEvent, _, cx| { + if !drawing { + return; + } + let b = ws.cloud.face_bounds; + let x = (f32::from(e.position.x - b.origin.x) / width).clamp(0., 1.); + let y = (f32::from(e.position.y - b.origin.y) / height).clamp(0., 1.); + ws.cloud.face_start = Some((x, y)); + ws.cloud.face_draft = None; + cx.notify(); + }), + ) + .on_mouse_move(cx.listener(move |ws, e: &MouseMoveEvent, _, cx| { + if let Some((x, y)) = ws.cloud.face_start { + let b = ws.cloud.face_bounds; + let ex = (f32::from(e.position.x - b.origin.x) / width).clamp(0., 1.); + let ey = (f32::from(e.position.y - b.origin.y) / height).clamp(0., 1.); + ws.cloud.face_draft = Some(FaceRect { + x: x.min(ex), + y: y.min(ey), + w: (ex - x).abs(), + h: (ey - y).abs(), + }); + cx.notify(); + } + })) + .on_mouse_up( + MouseButton::Left, + cx.listener(move |ws, _, _, cx| { + ws.cloud.face_start = None; + if let Some(rect) = ws + .cloud + .face_draft + .take() + .filter(|r| r.w > 0.005 && r.h > 0.005) + { + ws.cloud_face_name(&up, None, Some(rect), cx); + } + }), + ); + if !drawing { + for face in &asset.faces { + let a = asset.clone(); + let f = face.clone(); + let rect = &face.rect; + frame = frame.child( + div() + .id(SharedString::from(format!("face-{}", face.id))) + .absolute() + .left(px(rect.x * width)) + .top(px(rect.y * height)) + .w(px(rect.w * width)) + .h(px(rect.h * height)) + .border_2() + .border_color(gpui::rgb(if face.person_id.is_some() { + pal().green + } else { + pal().header + })) + .cursor_pointer() + .on_click(cx.listener(move |ws, _, _, cx| { + ws.cloud_face_name(&a, Some(&f), None, cx) + })), + ); + } + } + if let Some(r) = &ws.cloud.face_draft { + frame = frame.child( + div() + .absolute() + .left(px(r.x * width)) + .top(px(r.y * height)) + .w(px(r.w * width)) + .h(px(r.h * height)) + .border_2() + .border_color(gpui::rgb(pal().header)), + ); + } + body = body.child(frame); + } + body = body.child(chrome::gallery_button( + if ws.cloud.face_drawing { + "Cancel drawing" + } else { + "Add a face…" + }, + false, + |ws, _, cx| { + ws.cloud.face_drawing = !ws.cloud.face_drawing; + ws.cloud.face_start = None; + ws.cloud.face_draft = None; + cx.notify(); + }, + cx, + )); + for face in &asset.faces { + let person = face + .person_id + .as_ref() + .and_then(|id| { + ws.cloud + .people + .as_ref()? + .people + .iter() + .find(|p| &p.id == id) + }) + .map(|p| p.name.as_str()) + .unwrap_or("Unnamed"); + let a = asset.clone(); + let f = face.clone(); + let mut row = div() + .flex() + .gap_2() + .items_center() + .child(chrome::gallery_button( + format!("{}{}", person, if face.automatic { " · auto" } else { "" }), + false, + move |ws, _, cx| ws.cloud_face_name(&a, Some(&f), None, cx), + cx, + )); + for (label, method) in [("Not a face", "face.dismiss"), ("Not them", "face.reject")] { + if method == "face.reject" && !face.automatic && face.suggestion.is_none() { + continue; + } + let a = asset.clone(); + let f = face.clone(); + row = row.child(chrome::gallery_button( + label, + false, + move |ws, _, _| { + ws.cloud_mutate( + method, + vec![ + ("asset_id", a.id.clone().into()), + ("revision", a.revision.into()), + ("face_id", f.id.clone().into()), + ], + ) + }, + cx, + )); + } + if let Some(person) = face.suggestion.as_ref().and_then(|id| { + ws.cloud + .people + .as_ref()? + .people + .iter() + .find(|p| &p.id == id) + }) { + let name = person.name.clone(); + let a = asset.clone(); + let f = face.clone(); + row = row.child(chrome::gallery_button( + format!("Is this {name}? Yes"), + false, + move |ws, _, _| { + ws.cloud_mutate( + "face.name", + vec![ + ("asset_id", a.id.clone().into()), + ("revision", a.revision.into()), + ("face_id", f.id.clone().into()), + ("name", name.clone().into()), + ], + ) + }, + cx, + )); + } + body = body.child(row); + } + if asset.faces.is_empty() { + body = body.child( + "No faces named yet. Faces are found automatically; draw a box to add one by hand.", + ); + } + } else { + body = body.child("This photo is no longer available in this view."); + } + crate::ui::modal_frame( + "People in this photo", + 620., + body, + chrome::gallery_button("Close", false, |ws, _, cx| ws.close_modal(cx), cx), + ) + .into_any_element() +} +pub(crate) fn submit( + ws: &mut Workspace, + kind: &str, + get: impl Fn(&str) -> String, +) -> anyhow::Result { + match kind { + "people-rename" => { + anyhow::ensure!(!get("cloud-name").is_empty(), "Enter a name"); + ws.cloud_mutate( + "people.rename", + vec![ + ("id", get("cloud-person-id").into()), + ("name", get("cloud-name").into()), + ], + ); + } + "face-name" | "face-add" => { + anyhow::ensure!(!get("cloud-name").is_empty(), "Enter a name"); + let mut fields = vec![ + ("asset_id", get("cloud-asset-id").into()), + ("revision", get("cloud-revision").parse::()?.into()), + ("name", get("cloud-name").into()), + ]; + if kind == "face-add" { + fields.push(( + "rect", + value(&serde_json::from_str::(&get("cloud-face-rect"))?), + )); + } else { + fields.push(("face_id", Value::from(get("cloud-face-id")))); + } + ws.cloud_mutate( + if kind == "face-add" { + "face.add" + } else { + "face.name" + }, + fields, + ); + } + _ => return Ok(false), + } + Ok(true) +} diff --git a/crates/app/src/workspace/cloud_view.rs b/crates/app/src/workspace/cloud_view.rs new file mode 100644 index 00000000..2b5abc36 --- /dev/null +++ b/crates/app/src/workspace/cloud_view.rs @@ -0,0 +1,2162 @@ +//! The Schist Cloud gallery: the same room as the local library — the +//! strip, the sidebar, the grouped grid, the tray, the right-click menu +//! — showing a remote library over the workspace socket. Everything +//! visual comes from `gallery_chrome`; what this file adds is the +//! remote half: cloud folders and buckets in the sidebar, assets +//! grouped by month or folder in the grid, the server-side search box, +//! and the dialogs behind the mutations. The browser build, which has +//! no local gallery, composes its whole gallery from these parts. +use super::cloud::{CloudContext, PAGE_SIZE}; +use super::gallery_chrome::{ + self as chrome, cell_frame, empty_note, grid_column, grid_frame, lead_probe, menu_frame, + menu_row, menu_sep, pal, search_field, section_header, sidebar_link, sidebar_row_frame, + DragGhost, GroupBy, MenuAction, TrayInfo, +}; +#[cfg(target_arch = "wasm32")] +use super::gallery_chrome::{group_chips, sidebar_caption, sidebar_column}; +#[cfg(not(target_arch = "wasm32"))] +use super::library::GalleryDrag; +use super::*; +#[cfg(target_arch = "wasm32")] +#[derive(Clone)] +struct GalleryDrag { + paths: Vec, +} + +use crate::ui; +use gpui::{AppContext as _, StatefulInteractiveElement as _}; +use schist_cloud::{ + protocol::{map, value}, + Asset, Bucket, Filters, Folder, Rule, Scope, Value, +}; +use std::collections::BTreeMap; + +/// A drag of remote items — assets or a folder — headed for a bucket. +#[derive(Clone)] +struct RemoteDrag { + items: Vec, + label: String, +} +pub(crate) struct DragLabel(pub String); +#[derive(Clone)] +pub(crate) struct LocalFolderDrag { + pub path: PathBuf, +} +impl Render for DragLabel { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + div() + .px_3() + .py_2() + .bg(gpui::rgb(ui::palette().accent)) + .text_color(gpui::rgb(0xffffff)) + .child(self.0.clone()) + } +} +fn caption(text: impl Into) -> gpui::Div { + div() + .text_size(px(11.0)) + .text_color(gpui::rgb(ui::palette().text_dim)) + .child(text.into()) +} +fn form( + ws: &mut Workspace, + kind: &'static str, + fields: Vec<(&'static str, String, String)>, + cx: &mut Context, +) { + ws.open_modal(Modal::Cloud { kind, fields }, cx); +} +fn field( + key: &'static str, + label: &str, + value: impl Into, +) -> (&'static str, String, String) { + (key, label.into(), value.into()) +} +/// How many of a rule's filters are set, for the bucket header. +fn filter_count(f: &Filters) -> usize { + [ + f.mime_types.is_some(), + f.tags.is_some(), + f.edited.is_some(), + f.content.is_some(), + f.captured_after.is_some(), + f.captured_before.is_some(), + f.min_rating.is_some(), + f.bounds.is_some(), + ] + .into_iter() + .filter(|set| *set) + .count() +} + +/// A smart bucket's rule as a header subtitle: the query in quotes, +/// then how many filters it adds. +fn rule_label(rule: &Rule) -> String { + let mut parts = Vec::new(); + if !rule.text.trim().is_empty() { + parts.push(format!("\u{201c}{}\u{201d}", rule.text.trim())); + } + match filter_count(&rule.filters) { + 0 => {} + 1 => parts.push("1 filter".to_string()), + n => parts.push(format!("{n} filters")), + } + if parts.is_empty() { + "smart bucket".to_string() + } else { + parts.join(" · ") + } +} + +/// A cloud folder's name, and its path from the top for a subtitle. +fn folder_names(folders: &[Folder], id: &str) -> (String, String) { + let find = |id: &str| folders.iter().find(|f| f.id == id); + let Some(folder) = find(id) else { + return ("Folder".to_string(), String::new()); + }; + let mut path = vec![folder.name.clone()]; + let mut parent = folder.parent_id.clone(); + // A bounded walk: a cycle in the catalogue must not hang the + // render. + for _ in 0..32 { + let Some(next) = parent.as_deref().and_then(find) else { + break; + }; + path.push(next.name.clone()); + parent = next.parent_id.clone(); + } + path.reverse(); + (folder.name.clone(), path.join(" / ")) +} + +/// The folders as a tree: children under their parent, siblings by +/// name, as the file manager would show them, each with its depth. A +/// folder whose parent is off this page of the catalogue would +/// otherwise vanish, so orphans list at the top level. +fn folder_tree(folders: &[Folder]) -> Vec<(usize, Folder)> { + fn walk( + parent: Option<&str>, + depth: usize, + folders: &[Folder], + out: &mut Vec<(usize, Folder)>, + ) { + if depth > 16 { + return; + } + let mut children: Vec<&Folder> = folders + .iter() + .filter(|f| f.parent_id.as_deref() == parent) + .collect(); + children.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.cmp(&b.id))); + for child in children { + out.push((depth, child.clone())); + walk(Some(&child.id), depth + 1, folders, out); + } + } + let mut ordered = Vec::new(); + walk(None, 0, folders, &mut ordered); + let listed: std::collections::HashSet<&str> = + ordered.iter().map(|(_, f)| f.id.as_str()).collect(); + let orphans: Vec<(usize, Folder)> = folders + .iter() + .filter(|f| !listed.contains(f.id.as_str())) + .map(|f| (0, f.clone())) + .collect(); + ordered.extend(orphans); + ordered +} + +/// One page of assets grouped for the grid: a bucket or a search as +/// one strip, otherwise by month (newest first, a photo without a +/// capture time filing under its upload time) or by folder (the +/// unfiled last). +fn group_assets( + assets: &[Asset], + folders: &[Folder], + buckets: &[Bucket], + scope: &Scope, + text: &str, + group_by: GroupBy, +) -> Vec<(String, String, Vec)> { + if let Scope::Bucket { id } = scope { + let bucket = buckets.iter().find(|b| &b.id == id); + let name = bucket + .map(|b| b.name.clone()) + .unwrap_or_else(|| "Bucket".to_string()); + let rule = bucket + .and_then(|b| b.rule.as_ref()) + .map(rule_label) + .unwrap_or_default(); + let title = if text.trim().is_empty() { + format!("Bucket · {name}") + } else { + format!("Bucket · {name} · Search results") + }; + return vec![(title, rule, assets.to_vec())]; + } + if !text.trim().is_empty() { + let title = match scope { + Scope::Folder { id, .. } => { + format!("{} · Search results", folder_names(folders, id).0) + } + _ => "Search results".to_string(), + }; + return vec![(title, String::new(), assets.to_vec())]; + } + match group_by { + GroupBy::Folder => { + let mut groups: BTreeMap<(bool, String, String), Vec> = BTreeMap::new(); + for asset in assets { + let key = match &asset.folder_id { + Some(id) => { + let (name, path) = folder_names(folders, id); + (false, name, path) + } + None => (true, "Unfiled".to_string(), String::new()), + }; + groups.entry(key).or_default().push(asset.clone()); + } + groups + .into_iter() + .map(|((_, name, path), assets)| (name, path, assets)) + .collect() + } + GroupBy::Place => { + let mut places: BTreeMap> = BTreeMap::new(); + for asset in assets { + let name = asset + .place_name + .clone() + .unwrap_or_else(|| "No location".into()); + places.entry(name).or_default().push(asset.clone()); + } + let mut groups: Vec<_> = places + .into_iter() + .map(|(name, mut assets)| { + assets + .sort_by_key(|a| std::cmp::Reverse(a.captured_at.unwrap_or(a.modified_at))); + (name, String::new(), assets) + }) + .collect(); + groups.sort_by_key(|(name, _, assets)| { + (name == "No location", std::cmp::Reverse(assets.len())) + }); + groups + } + GroupBy::Date => { + let taken = |a: &Asset| a.captured_at.unwrap_or(a.modified_at); + let mut months: BTreeMap> = BTreeMap::new(); + for asset in assets { + let key = chrome::month_key(taken(asset) as i64); + months.entry(key).or_default().push(asset.clone()); + } + months + .into_iter() + .rev() + .map(|(key, mut assets)| { + assets.sort_by_key(|a| std::cmp::Reverse(taken(a))); + (chrome::month_title(&key), String::new(), assets) + }) + .collect() + } + } +} + +impl Workspace { + /// The order the provider sorts a page in: by relevance while + /// searching, newest first under month headers, by name under + /// folder headers. + pub(crate) fn cloud_sort(&self) -> String { + if !self.cloud.query.text.trim().is_empty() { + "relevance" + } else { + match self.gallery_group_by() { + GroupBy::Date => "captured_desc", + _ => "name", + } + } + .into() + } + + /// The strip's Refresh: the folder and bucket lists and the current + /// page, again. + pub(crate) fn cloud_refresh(&mut self, cx: &mut Context) { + self.cloud_refresh_catalogue(); + self.cloud_watch_assets(true); + cx.notify(); + } + + /// The lead of the selection — what Enter opens and the tray names. + pub(crate) fn cloud_lead_asset(&self) -> Option { + let id = self.cloud.selected.last()?; + self.cloud.assets.iter().find(|a| &a.id == id).cloned() + } + + fn cloud_asset(&self, id: &str) -> Option { + self.cloud.assets.iter().find(|a| a.id == id).cloned() + } + + fn cloud_is_selected(&self, id: &str) -> bool { + self.cloud.selected.iter().any(|s| s == id) + } + + pub(crate) fn cloud_select_single(&mut self, id: String) { + self.cloud.select_anchor = Some(id.clone()); + self.cloud.selected = vec![id]; + } + + fn cloud_toggle_selected(&mut self, id: String) { + if let Some(at) = self.cloud.selected.iter().position(|s| s == &id) { + self.cloud.selected.remove(at); + } else { + self.cloud.select_anchor = Some(id.clone()); + self.cloud.selected.push(id); + } + } + + /// Shift-click: select the display-order range from the anchor to + /// this asset, which becomes the lead. + fn cloud_select_range_to(&mut self, id: String) { + let flat = self.cloud_flat_order(); + let anchor = self + .cloud + .select_anchor + .clone() + .unwrap_or_else(|| id.clone()); + let (Some(a), Some(b)) = ( + flat.iter().position(|p| p == &anchor), + flat.iter().position(|p| p == &id), + ) else { + self.cloud_select_single(id); + return; + }; + let (lo, hi) = (a.min(b), a.max(b)); + let mut range: Vec = flat[lo..=hi].to_vec(); + if a > b { + range.reverse(); + } + self.cloud.select_anchor = Some(anchor); + self.cloud.selected = range; + } + + /// Every asset the grid is showing, in display order — what arrows + /// walk and Shift-clicks span. + pub(crate) fn cloud_flat_order(&self) -> Vec { + self.cloud_grouped() + .into_iter() + .flat_map(|(_, _, assets)| assets) + .map(|a| a.id) + .collect() + } + + /// The page grouped the way the sidebar's chips ask — the same + /// readings the local grid has. A bucket or a search shows as one strip, + /// exactly as locally. + pub(crate) fn cloud_grouped(&self) -> Vec<(String, String, Vec)> { + group_assets( + &self.cloud.assets, + &self.cloud.folders, + &self.cloud.buckets, + &self.cloud.query.scope, + &self.cloud.query.text, + self.gallery_group_by(), + ) + } + + /// A keystroke while the cloud search box has the keyboard. + pub(crate) fn cloud_search_key( + &mut self, + ev: &gpui::KeyDownEvent, + cx: &mut Context, + ) -> bool { + use crate::ui::LineEditKey; + match self.cloud.search.key(ev, cx) { + LineEditKey::Ignored => return false, + LineEditKey::Changed => self.cloud_search_changed(cx), + // Enter asks now rather than after the pause. + LineEditKey::Submitted => self.cloud_search_apply(cx), + LineEditKey::Moved => cx.notify(), + } + self.reset_caret_phase(); + true + } + + /// The text changed: ask the provider after a short pause, so a + /// word typed at speed is one query rather than six. Each change + /// supersedes the last. + fn cloud_search_changed(&mut self, cx: &mut Context) { + self.cloud.search_seq += 1; + let seq = self.cloud.search_seq; + cx.notify(); + cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(std::time::Duration::from_millis(250)) + .await; + let _ = this.update(cx, |ws, cx| { + if ws.cloud.search_seq == seq { + ws.cloud_search_apply(cx); + } + }); + }) + .detach(); + } + + /// Make the box's text the query. + fn cloud_search_apply(&mut self, cx: &mut Context) { + let text = self.cloud.search.text.trim().to_string(); + if self.cloud.query.text != text { + self.cloud.query.text = text; + self.cloud.query.offset = 0; + self.cloud_watch_assets(true); + } + cx.notify(); + } + + /// Leave the search: clear the box and show the folder again. + /// Wired into the always-on Escape path. Returns whether there was + /// a search to leave. + pub(crate) fn cloud_search_clear(&mut self, cx: &mut Context) -> bool { + if self.cloud.context.take().is_some() { + cx.notify(); + return true; + } + let searching = self.cloud.search.active + || !self.cloud.search.text.is_empty() + || !self.cloud.query.text.is_empty(); + if !searching { + return false; + } + self.cloud.search.clear(); + self.cloud.search_seq += 1; + if !self.cloud.query.text.is_empty() { + self.cloud.query.text.clear(); + self.cloud.query.offset = 0; + self.cloud_watch_assets(true); + } + cx.notify(); + true + } + + /// An arrow key while the cloud gallery has the keyboard: move the + /// selection through the page in display order — left/right by + /// one, up/down by a visual row. + pub(crate) fn cloud_nav_key( + &mut self, + ev: &gpui::KeyDownEvent, + cx: &mut Context, + ) -> bool { + if self.cloud.search.active { + return false; + } + if ev.keystroke.key == "space" { + self.cloud_people_view(cx); + return true; + } + let columns = self.cloud.grid.columns(self.gallery_thumb_px()) as isize; + let step: isize = match ev.keystroke.key.as_str() { + "left" => -1, + "right" => 1, + "up" => -columns, + "down" => columns, + _ => return false, + }; + let flat = self.cloud_flat_order(); + if flat.is_empty() { + return false; + } + let next = match self + .cloud + .selected + .last() + .and_then(|lead| flat.iter().position(|p| p == lead)) + { + Some(at) => (at as isize + step).clamp(0, flat.len() as isize - 1) as usize, + // Nothing selected yet: any arrow lands on the first photo. + None => 0, + }; + let lead = flat[next].clone(); + if ev.keystroke.modifiers.shift { + // Shift+arrow: the range from the anchor to wherever the + // lead moved, in display order. + let anchor = self + .cloud + .select_anchor + .clone() + .unwrap_or_else(|| lead.clone()); + let a = flat.iter().position(|p| p == &anchor).unwrap_or(next); + let (lo, hi) = (a.min(next), a.max(next)); + let mut range: Vec = flat[lo..=hi].to_vec(); + if a > next { + // The lead must stay last, so arrows keep moving it. + range.reverse(); + } + self.cloud.select_anchor = Some(anchor); + self.cloud.selected = range; + } else { + self.cloud_select_single(lead); + } + self.cloud.grid.reveal = true; + cx.notify(); + true + } + + /// Nudge the grid until the keyboard-moved selection is on screen. + pub(crate) fn cloud_reveal_tick(&mut self, cx: &mut Context) { + if self.cloud.grid.reveal_tick() { + cx.notify(); + } + } + + /// Everything the right-click menu acts on: the selection when the + /// clicked photo is in it, that photo alone otherwise. + fn cloud_acting(&self, id: &str) -> Vec { + if self.cloud_is_selected(id) { + self.cloud.selected.clone() + } else { + vec![id.to_string()] + } + } + + fn cloud_add_to_bucket(&mut self, bucket: String, ids: &[String]) { + let items = ids + .iter() + .map(|id| map([("kind", "asset".into()), ("id", id.clone().into())])) + .collect(); + self.cloud_drop_remote(bucket, items); + } + + fn cloud_remove_from_bucket(&mut self, bucket: String, ids: &[String]) { + self.cloud_mutate( + "bucket.remove", + vec![("id", bucket.into()), ("asset_ids", value(ids.to_vec()))], + ); + } +} + +/// The map-filter chip in the strip, when the cloud query carries a +/// drawn area — the one filter the gallery still offers beyond search. +pub(crate) fn filter_chip( + ws: &mut Workspace, + cx: &mut Context, +) -> Option { + #[cfg(not(target_arch = "wasm32"))] + if ws.cloud.query.filters.bounds.is_some() { + return Some( + chrome::filter_chip( + "Map filter: drawn area".to_string(), + |ws, cx| ws.open_map_filter(cx), + |ws, cx| ws.clear_map_filter(cx), + cx, + ) + .into_any_element(), + ); + } + let _ = (ws, cx); + None +} + +/// The search box in the strip: the provider ranks the page by it. +pub(crate) fn search_box(ws: &mut Workspace, cx: &mut Context) -> impl IntoElement { + let placeholder: SharedString = if ws.cloud.connected { + "Search photos\u{2026}".into() + } else { + "Connecting\u{2026}".into() + }; + let caret_on = ws.caret_on(); + search_field( + &ws.cloud.search, + placeholder, + caret_on, + |ws, _cx| ws.cloud.search.focus(), + |ws, cx| { + ws.cloud_search_clear(cx); + }, + cx, + ) +} + +/// What the tray says about the cloud gallery: the lead photo's name, +/// its Edit and Download buttons, the page's count. +pub(crate) fn tray_info(ws: &Workspace) -> TrayInfo { + let lead = ws.cloud_lead_asset(); + let one = ws.cloud.selected.len() == 1; + let mut notes = Vec::new(); + if ws.cloud.screening.pending > 0 { + notes.push(format!("Screening {} uploads…", ws.cloud.screening.pending)); + } + if ws.cloud.screening.blocked > 0 { + notes.push(format!( + "{} uploads unavailable after screening", + ws.cloud.screening.blocked + )); + } + if lead.as_ref().is_some_and(|a| a.edited) { + notes.push("edited — the edits live in Schist Cloud".to_string()); + } + type Act = chrome::TrayAction; + TrayInfo { + edit: lead.clone().map(|asset| { + Box::new( + move |ws: &mut Workspace, _w: &mut Window, cx: &mut Context| { + ws.cloud_open(asset.clone(), cx) + }, + ) as Act + }), + extra: one.then(|| { + ( + "Download…", + Box::new( + |ws: &mut Workspace, _w: &mut Window, cx: &mut Context| { + ws.cloud_download_selected(cx) + }, + ) as Act, + ) + }), + name: lead.map(|a| a.name), + selected: ws.cloud.selected.len(), + notes, + count: if ws.cloud.load_error.is_some() { + "Photos unavailable".to_string() + } else if ws.cloud.loaded { + chrome::photo_count(ws.cloud.total as usize) + } else if ws.cloud.connected { + "Loading\u{2026}".to_string() + } else { + ws.cloud.message.clone() + }, + } +} + +/// A row for a remote folder or bucket that takes drops: local gallery +/// photos, a watched local folder and files from the file manager +/// upload; remote items add by reference to a bucket. +fn droppable( + row: gpui::Stateful, + bucket: Option, + folder: Option, + cx: &mut Context, +) -> gpui::Stateful { + let (b1, f1) = (bucket.clone(), folder.clone()); + let (b2, f2) = (bucket.clone(), folder.clone()); + let (b3, f3) = (bucket.clone(), folder.clone()); + let mut row = row + .drag_over::(|s, _, _, _| s.bg(gpui::rgb(pal().select_border))) + .drag_over::(|s, _, _, _| s.bg(gpui::rgb(pal().select_border))) + .drag_over::(|s, _, _, _| s.bg(gpui::rgb(pal().select_border))) + .on_drop(cx.listener(move |ws, drag: &GalleryDrag, _, cx| { + cx.stop_propagation(); + ws.cloud_drop_local(b1.clone(), f1.clone(), drag.paths.clone(), cx) + })) + .on_drop(cx.listener(move |ws, drag: &LocalFolderDrag, _, cx| { + cx.stop_propagation(); + ws.cloud_drop_local(b2.clone(), f2.clone(), vec![drag.path.clone()], cx) + })) + .on_drop(cx.listener(move |ws, drag: &ExternalPaths, _, cx| { + cx.stop_propagation(); + ws.cloud_drop_local(b3.clone(), f3.clone(), drag.paths().to_vec(), cx) + })); + if let Some(bucket) = bucket { + row = row + .drag_over::(|s, _, _, _| s.bg(gpui::rgb(pal().select_border))) + .on_drop(cx.listener(move |ws, drag: &RemoteDrag, _, cx| { + cx.stop_propagation(); + ws.cloud_drop_remote(bucket.clone(), drag.items.clone()); + cx.notify(); + })); + } + row +} + +/// The cloud rows' badge: the one thing that tells a cloud folder or +/// bucket from a local one in the same list. +pub(crate) const CLOUD_GLYPH: &str = "\u{2601}"; + +/// The "+ New folder" answer for the cloud. +pub(crate) fn new_cloud_folder(ws: &mut Workspace, cx: &mut Context) { + form( + ws, + "new-folder", + vec![field("cloud-name", "Folder name", "")], + cx, + ) +} + +/// The "+ New bucket" answer for the cloud: the same dialog as a local +/// bucket — name, search, an area on the map — sent to the provider. +/// The browser, which has no map, gets the name and search fields. +pub(crate) fn new_cloud_bucket(ws: &mut Workspace, cx: &mut Context) { + ws.cloud.form_target = None; + ws.cloud.form_scope = match &ws.cloud.query.scope { + Scope::Bucket { .. } => Scope::Library, + s => s.clone(), + }; + #[cfg(not(target_arch = "wasm32"))] + { + // A fresh rule: no leftover area from the import dialog or the + // map filter can pass as this bucket's. + ws.library.map.selection = None; + ws.library.map.selection_name = None; + ws.open_modal( + Modal::BucketName { + name: String::new(), + query: String::new(), + photos: Vec::new(), + editing: None, + cloud: true, + }, + cx, + ); + ws.focus_field("bucket-name", ""); + } + #[cfg(target_arch = "wasm32")] + form( + ws, + "new-bucket", + vec![ + field("cloud-name", "Bucket name", ""), + field("cloud-query", "Search", ""), + ], + cx, + ); +} + +/// Reopen the bucket dialog on a cloud bucket: rename it, give it a +/// rule, change or remove the one it has. +pub(crate) fn edit_cloud_bucket(ws: &mut Workspace, bucket: Bucket, cx: &mut Context) { + ws.cloud.form_target = Some((bucket.id.clone(), bucket.revision)); + ws.cloud.form_scope = bucket + .rule + .as_ref() + .map(|r| r.scope.clone()) + .unwrap_or(Scope::Library); + let text = bucket + .rule + .as_ref() + .map(|r| r.text.clone()) + .unwrap_or_default(); + #[cfg(not(target_arch = "wasm32"))] + { + // Show the rule being edited: the shared map takes the + // bucket's area (and jumps to it), or clears. + match bucket.rule.as_ref().and_then(|r| r.filters.bounds.as_ref()) { + Some(b) => ws.library.map.jump_to( + "the saved area", + crate::workspace::GeoBounds { + south: b.south, + north: b.north, + west: b.west, + east: b.east, + }, + ), + None => { + ws.library.map.selection = None; + ws.library.map.selection_name = None; + } + } + ws.open_modal( + Modal::BucketName { + name: bucket.name.clone(), + query: text, + photos: Vec::new(), + editing: None, + cloud: true, + }, + cx, + ); + } + #[cfg(target_arch = "wasm32")] + form( + ws, + "edit-bucket", + vec![ + field("cloud-name", "Bucket name", bucket.name.clone()), + field("cloud-query", "Search", text), + ], + cx, + ); +} + +/// The Schist Cloud rows of the FOLDERS list: the library itself as a +/// root, its folders as a tree beneath it, the page links — or, signed +/// out, the way in. +pub(crate) fn folder_rows( + ws: &mut Workspace, + cx: &mut Context, +) -> Vec { + let mut rows: Vec = Vec::new(); + if ws.cloud.account.is_none() { + rows.push( + sidebar_link( + format!("{CLOUD_GLYPH} Sign into Schist Cloud…"), + |ws, _w, cx| ws.cloud_sign_in(cx), + cx, + ) + .into_any_element(), + ); + return rows; + } + let showing = ws.cloud.show; + let scope = ws.cloud.query.scope.clone(); + // The whole library, the root the folders hang from. + { + let selected = showing && scope == Scope::Library; + let row = sidebar_row_frame( + "cloud-library", + format!("{CLOUD_GLYPH} Schist Cloud"), + ws.cloud + .library_total + .and_then(|count| usize::try_from(count).ok()), + selected, + 0, + ) + .children( + (ws.cloud.library_total.is_none() && ws.cloud.is_loading()) + .then(|| chrome::loading_spinner("cloud-library-loading")), + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(|ws, _e: &MouseDownEvent, _w, cx| ws.cloud_browse(Scope::Library, cx)), + ) + .on_mouse_down( + MouseButton::Right, + cx.listener(|ws, ev: &MouseDownEvent, _w, cx| { + ws.cloud.context = Some((ev.position, CloudContext::Library)); + #[cfg(not(target_arch = "wasm32"))] + { + ws.library.context = None; + } + cx.notify(); + }), + ); + rows.push(droppable(row, None, None, cx).into_any_element()); + } + let ordered = folder_tree(&ws.cloud.folders); + for (i, (depth, folder)) in ordered.into_iter().enumerate() { + let selected = showing && matches!(&scope, Scope::Folder { id, .. } if id == &folder.id); + let browse = folder.id.clone(); + let context = folder.id.clone(); + let drag = RemoteDrag { + items: vec![map([ + ("kind", "folder".into()), + ("id", folder.id.clone().into()), + ("recursive", true.into()), + ])], + label: folder.name.clone(), + }; + let row = sidebar_row_frame( + ("cloud-folder", i), + format!("\u{25b8} {}", folder.name), + folder + .asset_count + .and_then(|count| usize::try_from(count).ok()), + selected, + depth + 1, + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |ws, _e: &MouseDownEvent, _w, cx| { + ws.cloud_browse( + Scope::Folder { + id: browse.clone(), + recursive: true, + }, + cx, + ) + }), + ) + .on_mouse_down( + MouseButton::Right, + cx.listener(move |ws, ev: &MouseDownEvent, _w, cx| { + ws.cloud.context = Some((ev.position, CloudContext::Folder(context.clone()))); + #[cfg(not(target_arch = "wasm32"))] + { + ws.library.context = None; + } + cx.notify(); + }), + ) + .on_drag(drag, |drag, _, _, cx| { + cx.new(|_| DragLabel(drag.label.clone())) + }); + rows.push(droppable(row, None, Some(folder.id.clone()), cx).into_any_element()); + } + rows.extend(catalogue_pages(true, ws, cx)); + rows +} + +/// The Schist Cloud rows of the BUCKETS list, after the local ones. +pub(crate) fn bucket_rows( + ws: &mut Workspace, + cx: &mut Context, +) -> Vec { + let mut rows: Vec = Vec::new(); + if ws.cloud.account.is_none() { + return rows; + } + let showing = ws.cloud.show; + let scope = ws.cloud.query.scope.clone(); + for (i, bucket) in ws.cloud.buckets.clone().into_iter().enumerate() { + let this = Scope::Bucket { + id: bucket.id.clone(), + }; + let selected = showing && scope == this; + let browse = bucket.id.clone(); + let context = bucket.id.clone(); + let label = if bucket.rule.is_some() { + format!("{CLOUD_GLYPH} \u{2726} {}", bucket.name) + } else { + format!("{CLOUD_GLYPH} {}", bucket.name) + }; + let row = sidebar_row_frame( + ("cloud-bucket", i), + label, + bucket + .asset_count + .and_then(|count| usize::try_from(count).ok()), + selected, + 0, + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |ws, _e: &MouseDownEvent, _w, cx| { + ws.cloud_browse(Scope::Bucket { id: browse.clone() }, cx) + }), + ) + .on_mouse_down( + MouseButton::Right, + cx.listener(move |ws, ev: &MouseDownEvent, _w, cx| { + ws.cloud.context = Some((ev.position, CloudContext::Bucket(context.clone()))); + #[cfg(not(target_arch = "wasm32"))] + { + ws.library.context = None; + } + cx.notify(); + }), + ); + rows.push(droppable(row, Some(bucket.id.clone()), None, cx).into_any_element()); + } + rows.extend(catalogue_pages(false, ws, cx)); + // A library with more folders or buckets than one page lists gets + // the finder; a smaller one has them all on screen already. + if ws.cloud.folders_total > 500 + || ws.cloud.buckets_total > 500 + || !ws.cloud.catalogue.is_empty() + { + rows.push( + sidebar_link( + if ws.cloud.catalogue.is_empty() { + "Find folders / buckets…".to_string() + } else { + format!("Showing \u{201c}{}\u{201d}…", ws.cloud.catalogue) + }, + |ws, _w, cx| { + form( + ws, + "catalogue", + vec![field( + "cloud-query", + "Name contains", + ws.cloud.catalogue.clone(), + )], + cx, + ) + }, + cx, + ) + .into_any_element(), + ); + } + rows +} + +/// Previous/More links under a folder or bucket list that overflows +/// one page of the catalogue. +fn catalogue_pages( + folders: bool, + ws: &Workspace, + cx: &mut Context, +) -> Vec { + let (offset, total) = if folders { + (ws.cloud.folders_offset, ws.cloud.folders_total) + } else { + (ws.cloud.buckets_offset, ws.cloud.buckets_total) + }; + let mut links = Vec::new(); + if offset > 0 { + links.push( + sidebar_link( + "\u{2191} Previous", + move |ws, _w, cx| { + if folders { + ws.cloud.folders_offset = offset.saturating_sub(500); + } else { + ws.cloud.buckets_offset = offset.saturating_sub(500); + } + ws.cloud_refresh_catalogue(); + cx.notify(); + }, + cx, + ) + .into_any_element(), + ); + } + if offset + 500 < total { + links.push( + sidebar_link( + "\u{2193} More", + move |ws, _w, cx| { + if folders { + ws.cloud.folders_offset = offset + 500; + } else { + ws.cloud.buckets_offset = offset + 500; + } + ws.cloud_refresh_catalogue(); + cx.notify(); + }, + cx, + ) + .into_any_element(), + ); + } + links +} + +/// Why the cloud grid is bare. +fn empty_reason(ws: &Workspace) -> String { + if let Some(error) = &ws.cloud.load_error { + return format!("Could not load photos: {error}. Use Refresh to try again."); + } + if !ws.cloud.connected { + return ws.cloud.message.clone(); + } + if !ws.cloud.loaded { + return "Loading\u{2026}".into(); + } + if !ws.cloud.query.text.trim().is_empty() { + return "Nothing matches the search. Escape clears it.".into(); + } + if ws.cloud.query.filters.bounds.is_some() { + return "Nothing inside the map filter. The chip in the strip clears it.".into(); + } + match &ws.cloud.query.scope { + Scope::Bucket { id } => { + let smart = ws + .cloud + .buckets + .iter() + .any(|b| &b.id == id && b.rule.is_some()); + if smart { + "Nothing matches this bucket's rule yet. Dragging photos in works too." + } else { + "This bucket is empty. Drag photos onto its row in the sidebar to add them." + } + } + Scope::Folder { .. } => { + "This cloud folder is empty. Drop photos on its row in the sidebar, or use \ + Import…" + } + Scope::Library => { + "No photos in your cloud library yet. Use Import…, or drag photos from \ + the local gallery onto a cloud folder or bucket." + } + } + .into() +} + +/// The grid: month or folder headers with a rule, then wrapped +/// thumbnails — one page of the query, with the page links under it. +pub(crate) fn grid(ws: &mut Workspace, cx: &mut Context) -> gpui::AnyElement { + let cell = ws.gallery_thumb_px(); + let selected = ws.cloud.selected.clone(); + let mut sections = ws.cloud_grouped(); + if let Some(id) = &ws.cloud.query.filters.person_id { + let name = if id == "unnamed" { + "Unnamed faces" + } else { + ws.cloud + .people + .as_ref() + .and_then(|p| p.people.iter().find(|p| &p.id == id)) + .map(|p| p.name.as_str()) + .unwrap_or("Person") + }; + for (title, _, _) in &mut sections { + *title = format!("People · {name} · {title}"); + } + } + let access: chrome::GridAccess = |ws| &mut ws.cloud.grid; + let mut column = grid_column("cloud-grid", &ws.cloud.grid, access, cx); + if ws.cloud.is_loading() { + column = column.child(chrome::loading_note()); + } + // Bucket/search grouping creates a section even with no assets. Do not + // render its zero count until the first snapshot confirms it is empty. + if !ws.cloud.loaded && ws.cloud.assets.is_empty() { + if !ws.cloud.is_loading() { + column = column.child(empty_note(empty_reason(ws))); + } + return grid_frame(column, &ws.cloud.grid, access, cx).into_any_element(); + } + if sections.is_empty() { + column = column.child(empty_note(empty_reason(ws))); + } + let columns = ws.cloud.grid.columns(cell); + for (title, subtitle, assets) in sections { + let detail = if !ws.cloud.loaded { + "Updating…".to_string() + } else if subtitle.is_empty() { + chrome::photo_count(assets.len()) + } else { + format!("{subtitle} — {}", assets.len()) + }; + column = column.child(section_header(title, detail)); + let mut body = div().flex().flex_col(); + for row_assets in assets.chunks(columns) { + let mut row = div().flex().flex_row().gap_2().mb_2(); + for asset in row_assets { + row = row.child(cloud_cell(ws, asset.clone(), cell, &selected, cx)); + } + body = body.child(row); + } + column = column.child(body); + } + // The page links, in the grid's own voice, only when there is + // more than one page. + let offset = ws.cloud.query.offset; + let total = ws.cloud.total; + if ws.cloud.loaded && (offset > 0 || offset + PAGE_SIZE < total) { + let first = offset + 1; + let last = (offset + PAGE_SIZE).min(total); + let link = + |label: &'static str, to: u64, cx: &mut Context| -> gpui::AnyElement { + div() + .px_2() + .h(px(24.0)) + .flex() + .items_center() + .rounded_md() + .text_size(px(12.0)) + .text_color(gpui::rgb(pal().header)) + .cursor_pointer() + .hover(|s| s.bg(gpui::rgb(pal().sidebar_selected))) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |ws, _e: &MouseDownEvent, _w, cx| { + ws.cloud.query.offset = to; + ws.cloud.selected.clear(); + ws.cloud.select_anchor = None; + ws.cloud_watch_assets(false); + cx.notify(); + }), + ) + .child(label) + .into_any_element() + }; + let mut pager = div().flex().flex_row().items_center().gap_2().pt_2().pb_4(); + if offset > 0 { + pager = pager.child(link( + "\u{2190} Previous page", + offset.saturating_sub(PAGE_SIZE), + cx, + )); + } + pager = pager.child( + div() + .text_size(px(11.0)) + .text_color(gpui::rgb(pal().text_dim)) + .child(format!("{first}–{last} of {total}")), + ); + if offset + PAGE_SIZE < total { + pager = pager.child(link("Next page \u{2192}", offset + PAGE_SIZE, cx)); + } + column = column.child(pager); + } + grid_frame(column, &ws.cloud.grid, access, cx).into_any_element() +} + +/// One remote photo's square: its thumbnail once fetched, the same +/// selection, drag and menu behaviour as a local cell. +fn cloud_cell( + ws: &mut Workspace, + asset: Asset, + cell: f32, + selected: &[String], + cx: &mut Context, +) -> impl IntoElement { + let thumb = ws + .cloud + .thumbnails + .get(&asset.id) + .map(|(_, image)| image.clone()); + let failed = asset.thumbnail_url.is_none() || ws.cloud.thumbnail_failed.contains(&asset.id); + let ghost_thumb = thumb.clone(); + let is_selected = selected.iter().any(|id| id == &asset.id); + let is_lead = selected.last() == Some(&asset.id); + let click = asset.clone(); + let context = asset.id.clone(); + // Dragging carries the whole selection when the pressed cell is in + // it, and just the pressed cell otherwise. + let carried: Vec = if is_selected { + selected.to_vec() + } else { + vec![asset.id.clone()] + }; + let drag = RemoteDrag { + items: carried + .iter() + .map(|id| map([("kind", "asset".into()), ("id", id.clone().into())])) + .collect(), + label: if carried.len() == 1 { + asset.name.clone() + } else { + format!("{} photos", carried.len()) + }, + }; + cell_frame( + SharedString::from(format!("cloud-cell-{}", asset.id)), + cell, + is_selected, + thumb, + failed, + asset.edited, + ) + .on_drag(drag, move |drag, _offset, _window, cx| { + let label = drag.label.clone(); + let count = drag.items.len(); + let thumb = ghost_thumb.clone(); + cx.new(|_| DragGhost { + label, + thumb, + count, + size: cell, + }) + }) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |ws, ev: &MouseDownEvent, _w, cx| { + let id = click.id.clone(); + if ev.modifiers.platform || ev.modifiers.control { + // ⌘-click: in or out, keeping the rest. + ws.cloud_toggle_selected(id); + } else if ev.modifiers.shift { + ws.cloud_select_range_to(id); + } else if ev.click_count >= 2 { + ws.cloud_select_single(id); + ws.cloud_open(click.clone(), cx); + } else if !ws.cloud_is_selected(&id) { + // A plain press on an unselected photo selects it — + // and on a selected one keeps the selection, so a + // drag can carry the lot. + ws.cloud_select_single(id); + } + ws.cloud.context = None; + cx.notify(); + }), + ) + .on_mouse_down( + MouseButton::Right, + cx.listener(move |ws, ev: &MouseDownEvent, _w, cx| { + // Right-click acts on the selection when it lands in + // it, on this photo alone otherwise. + if !ws.cloud_is_selected(&context) { + ws.cloud_select_single(context.clone()); + } + ws.cloud.context = Some((ev.position, CloudContext::Photo(context.clone()))); + #[cfg(not(target_arch = "wasm32"))] + { + ws.library.context = None; + } + cx.notify(); + }), + ) + .children(is_lead.then(|| lead_probe(|ws| &mut ws.cloud.grid, cx))) +} + +/// The cloud gallery's right-click menu: on a photo, a folder row or a +/// bucket row. +pub(crate) fn context_menu( + ws: &mut Workspace, + cx: &mut Context, +) -> Option { + let (position, target) = ws.cloud.context.clone()?; + let dismiss: fn(&mut Workspace) = |ws| ws.cloud.context = None; + let mut rows: Vec = Vec::new(); + let row = |rows: &mut Vec, + label: String, + act: MenuAction, + cx: &mut Context| { + rows.push(menu_row(label, dismiss, act, cx)); + }; + match target { + CloudContext::Photo(id) => { + let acting = ws.cloud_acting(&id); + let n = acting.len(); + if let Some(asset) = ws.cloud_asset(&id) { + row( + &mut rows, + "Edit".into(), + std::rc::Rc::new(move |ws, _w, cx| ws.cloud_open(asset.clone(), cx)), + cx, + ); + } + if n == 1 { + row( + &mut rows, + "View & name people".into(), + std::rc::Rc::new(|ws, _, cx| ws.cloud_people_view(cx)), + cx, + ); + row( + &mut rows, + "Download\u{2026}".into(), + std::rc::Rc::new(|ws, _w, cx| ws.cloud_download_selected(cx)), + cx, + ); + } + rows.push(menu_sep()); + for bucket in ws.cloud.buckets.clone() { + let add = acting.clone(); + row( + &mut rows, + format!("Add to {}", bucket.name), + std::rc::Rc::new(move |ws, _w, _cx| { + ws.cloud_add_to_bucket(bucket.id.clone(), &add) + }), + cx, + ); + } + row( + &mut rows, + "Add to new bucket\u{2026}".into(), + std::rc::Rc::new(|ws, _w, cx| new_cloud_bucket(ws, cx)), + cx, + ); + if let Scope::Bucket { id: bucket } = ws.cloud.query.scope.clone() { + let remove = acting.clone(); + row( + &mut rows, + if n > 1 { + format!("Remove {n} from this bucket") + } else { + "Remove from this bucket".into() + }, + std::rc::Rc::new(move |ws, _w, _cx| { + ws.cloud_remove_from_bucket(bucket.clone(), &remove) + }), + cx, + ); + } + rows.push(menu_sep()); + if let Some(asset) = ws.cloud_asset(&id) { + row( + &mut rows, + "Delete from Schist Cloud\u{2026}".into(), + std::rc::Rc::new(move |ws, _w, cx| { + ws.cloud.form_target = Some((asset.id.clone(), asset.revision)); + form(ws, "delete-asset", vec![], cx) + }), + cx, + ); + } + } + CloudContext::Library => { + row( + &mut rows, + "Upload files here\u{2026}".into(), + std::rc::Rc::new(|ws, _w, cx| ws.cloud_pick_upload_to(None, None, false, cx)), + cx, + ); + row( + &mut rows, + "Upload folder here\u{2026}".into(), + std::rc::Rc::new(|ws, _w, cx| ws.cloud_pick_upload_to(None, None, true, cx)), + cx, + ); + #[cfg(not(target_arch = "wasm32"))] + row( + &mut rows, + "Download everything\u{2026}".into(), + std::rc::Rc::new(|ws, _w, cx| ws.cloud_download_scope(Scope::Library, cx)), + cx, + ); + rows.push(menu_sep()); + row( + &mut rows, + "New folder\u{2026}".into(), + std::rc::Rc::new(|ws, _w, cx| { + ws.cloud.form_target = None; + new_cloud_folder(ws, cx) + }), + cx, + ); + } + CloudContext::Folder(id) => { + let folder = ws.cloud.folders.iter().find(|f| f.id == id).cloned()?; + let into_files = folder.id.clone(); + row( + &mut rows, + "Upload files here\u{2026}".into(), + std::rc::Rc::new(move |ws, _w, cx| { + ws.cloud_pick_upload_to(None, Some(into_files.clone()), false, cx) + }), + cx, + ); + let into_dir = folder.id.clone(); + row( + &mut rows, + "Upload folder here\u{2026}".into(), + std::rc::Rc::new(move |ws, _w, cx| { + ws.cloud_pick_upload_to(None, Some(into_dir.clone()), true, cx) + }), + cx, + ); + #[cfg(not(target_arch = "wasm32"))] + { + let download = folder.id.clone(); + row( + &mut rows, + "Download folder\u{2026}".into(), + std::rc::Rc::new(move |ws, _w, cx| { + ws.cloud_download_scope( + Scope::Folder { + id: download.clone(), + recursive: true, + }, + cx, + ) + }), + cx, + ); + } + rows.push(menu_sep()); + let rename = folder.clone(); + row( + &mut rows, + "Rename\u{2026}".into(), + std::rc::Rc::new(move |ws, _w, cx| { + ws.cloud.form_target = Some((rename.id.clone(), rename.revision)); + form( + ws, + "rename-folder", + vec![field("cloud-name", "Name", rename.name.clone())], + cx, + ) + }), + cx, + ); + let parent = folder.id.clone(); + row( + &mut rows, + "New folder inside\u{2026}".into(), + std::rc::Rc::new(move |ws, _w, cx| { + ws.cloud.form_target = Some((parent.clone(), 0)); + form( + ws, + "new-subfolder", + vec![field("cloud-name", "Folder name", "")], + cx, + ) + }), + cx, + ); + rows.push(menu_sep()); + let delete = folder; + row( + &mut rows, + "Delete\u{2026}".into(), + std::rc::Rc::new(move |ws, _w, cx| { + ws.cloud.form_target = Some((delete.id.clone(), delete.revision)); + form( + ws, + "delete-folder", + vec![( + "cloud-check-contents", + "Also delete every photo and sub-folder inside".into(), + String::new(), + )], + cx, + ) + }), + cx, + ); + } + CloudContext::Cluster(ids) => { + rows.push( + div() + .px_2() + .py_1() + .text_size(px(11.0)) + .text_color(gpui::rgb(ui::palette().text_dim)) + .child(format!("{} photos in this marker", ids.len())) + .into_any_element(), + ); + for bucket in ws.cloud.buckets.clone() { + let add = ids.clone(); + row( + &mut rows, + format!("Add all to {}", bucket.name), + std::rc::Rc::new(move |ws, _w, _cx| { + ws.cloud_add_to_bucket(bucket.id.clone(), &add) + }), + cx, + ); + } + row( + &mut rows, + "Add all to new bucket\u{2026}".into(), + std::rc::Rc::new(|ws, _w, cx| new_cloud_bucket(ws, cx)), + cx, + ); + } + CloudContext::Person(id) => { + let person = ws + .cloud + .people + .as_ref() + .and_then(|p| p.people.iter().find(|p| p.id == id)) + .cloned()?; + let rename = person.clone(); + row( + &mut rows, + "Rename or merge\u{2026}".into(), + std::rc::Rc::new(move |ws, _w, cx| { + ws.open_modal( + Modal::Cloud { + kind: "people-rename", + fields: vec![ + ("cloud-person-id", "".into(), rename.id.clone()), + ("cloud-name", "Name".into(), rename.name.clone()), + ], + }, + cx, + ) + }), + cx, + ); + rows.push(menu_sep()); + row( + &mut rows, + "Forget this person".into(), + std::rc::Rc::new(move |ws, _w, cx| { + ws.cloud_mutate("people.forget", vec![("id", person.id.clone().into())]); + cx.notify(); + }), + cx, + ); + } + CloudContext::Bucket(id) => { + let bucket = ws.cloud.buckets.iter().find(|b| b.id == id).cloned()?; + let edit = bucket.clone(); + row( + &mut rows, + "Edit bucket\u{2026}".into(), + std::rc::Rc::new(move |ws, _w, cx| edit_cloud_bucket(ws, edit.clone(), cx)), + cx, + ); + // How many the bucket holds: the row's count, or the page + // total when the bucket is the one on screen. + let on_screen = ws.cloud.show + && ws.cloud.loaded + && matches!(&ws.cloud.query.scope, Scope::Bucket { id: on } if on == &bucket.id); + let count = if on_screen { + Some(ws.cloud.total) + } else { + bucket.asset_count + }; + let counted = |label: &str| match count { + Some(n) => format!("{label} ({n})"), + None => label.to_string(), + }; + let select = bucket.id.clone(); + row( + &mut rows, + counted("Select all"), + std::rc::Rc::new(move |ws, _w, cx| ws.cloud_select_all_bucket(select.clone(), cx)), + cx, + ); + if !ws.cloud.selected.is_empty() { + let add = ws.cloud.selected.clone(); + let into = bucket.id.clone(); + row( + &mut rows, + format!("Add selected ({})", add.len()), + std::rc::Rc::new(move |ws, _w, _cx| ws.cloud_add_to_bucket(into.clone(), &add)), + cx, + ); + } + rows.push(menu_sep()); + #[cfg(not(target_arch = "wasm32"))] + { + let zip = bucket.clone(); + row( + &mut rows, + counted("Save all as ZIP\u{2026}"), + std::rc::Rc::new(move |ws, _w, cx| ws.cloud_zip_bucket(zip.clone(), cx)), + cx, + ); + let batch = bucket.clone(); + row( + &mut rows, + counted("Process all\u{2026}"), + std::rc::Rc::new(move |ws, _w, cx| ws.cloud_process_bucket(batch.clone(), cx)), + cx, + ); + } + let moving = bucket.clone(); + row( + &mut rows, + "Move all to folder\u{2026}".into(), + std::rc::Rc::new(move |ws, _w, cx| ws.cloud_move_bucket(&moving, cx)), + cx, + ); + rows.push(menu_sep()); + // A smart bucket's matches come back on the next pass; only + // the hand-added photos are the user's to clear. + let clear = bucket.clone(); + row( + &mut rows, + if bucket.rule.is_some() { + "Clear added photos".into() + } else { + "Clear bucket".into() + }, + std::rc::Rc::new(move |ws, _w, _cx| ws.cloud_clear_bucket(&clear)), + cx, + ); + let delete = bucket; + row( + &mut rows, + "Delete bucket\u{2026}".into(), + std::rc::Rc::new(move |ws, _w, cx| { + ws.cloud.form_target = Some((delete.id.clone(), delete.revision)); + form(ws, "delete-bucket", vec![], cx) + }), + cx, + ); + } + } + Some(menu_frame(position, rows, dismiss, cx)) +} + +pub(crate) fn dialog( + ws: &mut Workspace, + kind: &'static str, + fields: Vec<(&'static str, String, String)>, + cx: &mut Context, +) -> gpui::AnyElement { + if kind == "storage-warning" { + let message = fields + .first() + .map(|(_, _, value)| value.clone()) + .unwrap_or_default(); + let body = div().child(message); + let actions = ui::button("OK", true, |ws, _, cx| ws.close_modal(cx), cx); + return ui::modal_frame("Not enough cloud storage", 540.0, body, actions) + .into_any_element(); + } + if kind == "people-view" { + return super::cloud_people::viewer(ws, fields, cx); + } + let title = match kind { + "people-rename" => "Rename or merge person", + "face-name" | "face-add" => "Name this face", + "sign-in" => "Sign into Schist Cloud", + "search" => "Search photos", + "catalogue" => "Find cloud folders and buckets", + "new-folder" | "new-subfolder" => "New cloud folder", + "new-bucket" => "New cloud bucket", + "edit-bucket" => "Edit cloud bucket", + "rename-folder" => "Rename cloud folder", + "delete-folder" => "Delete cloud folder?", + "delete-bucket" => "Delete cloud bucket?", + "delete-asset" => "Delete cloud photo?", + "upload-document" => "Upload document to Schist Cloud", + "move-items" => "Move to cloud folder", + "upload-folder" => "Upload folder to Schist Cloud", + "download" => "Download cloud photo", + _ => "Schist Cloud", + }; + let mut body = div().flex().flex_col().gap_2(); + if kind == "people-rename" { + let id = fields + .iter() + .find(|(k, _, _)| *k == "cloud-person-id") + .map(|(_, _, v)| v.clone()) + .unwrap_or_default(); + body = body + .child("An existing name merges the two people. Forgetting removes names, not photos.") + .child(chrome::gallery_button( + "Forget person", + false, + move |ws, _, cx| { + ws.cloud_mutate("people.forget", vec![("id", id.clone().into())]); + ws.close_modal(cx); + }, + cx, + )); + } + if kind.starts_with("delete-") { + body = body.child(caption(match kind { + "delete-folder" => { + "An empty folder is removed from your library. Tick the box to remove \ + its photos and sub-folders too — for good, with their cloud edits." + } + "delete-asset" => { + "The photo and its cloud edits are removed for good; buckets holding it \ + let it go." + } + _ => "Photos remain in your cloud library.", + })); + } + if kind == "upload-folder" { + body = body.child(caption( + "Every photo in the folder uploads into the chosen cloud folder, keeping its \ + sub-folders. The local files stay where they are.", + )); + } + if kind == "move-items" { + body = body.child(caption( + "Every photo in the bucket is filed into the chosen folder; its cloud edits and \ + bucket memberships stay as they are.", + )); + } + if kind.ends_with("bucket") && !kind.starts_with("delete") { + body = body.child(caption( + "Leave the search empty for a bucket filled by dragging photos in.", + )); + } + for (key, label, committed) in fields { + if label.is_empty() { + continue; + } + if key.starts_with("cloud-check-") { + // A yes/no field: "1" when ticked, empty otherwise. + let entity = cx.entity(); + body = body.child( + schist_ui::Checkbox::new(key, label.clone(), committed == "1").on_change( + move |checked, _window, cx| { + let value = if *checked { "1" } else { "" }.to_string(); + entity.update(cx, |ws, cx| { + ws.update_modal(|modal| { + if let Modal::Cloud { fields, .. } = modal { + if let Some((_, _, v)) = + fields.iter_mut().find(|(k, _, _)| *k == key) + { + *v = value.clone(); + } + } + }); + cx.notify(); + }); + }, + ), + ); + continue; + } + if key == "cloud-download-format" { + let mut options = vec![(String::new(), "Current editable document".to_string())]; + if let Some(capabilities) = &ws.cloud.capabilities { + if capabilities.original_download { + options.push(("original".into(), "Original file".into())); + } + let mut seen = std::collections::HashSet::new(); + for format in &capabilities.formats { + if !format.can_export { + continue; + } + for extension in &format.extensions { + if extension != "original" + && schist_cloud::transfer::valid_format(extension) + && seen.insert(extension.clone()) + { + options.push(( + extension.clone(), + format!("{} (.{})", format.name, extension), + )); + } + } + } + } + let mut choices = div() + .id("cloud-download-formats") + .flex() + .flex_col() + .gap_1() + .max_h(px(320.0)) + .overflow_y_scroll(); + for (id, name) in options { + let display = format!("{} {}", if id == committed { "●" } else { "○" }, name); + choices = choices.child(ui::button( + display, + false, + move |ws, _, cx| { + ws.update_modal(|modal| { + if let Modal::Cloud { fields, .. } = modal { + if let Some((_, _, selected)) = fields + .iter_mut() + .find(|(key, _, _)| *key == "cloud-download-format") + { + *selected = id.clone(); + } + } + }); + cx.notify(); + }, + cx, + )); + } + body = body.child(ui::field_row("Format", choices)); + continue; + } + if key == "cloud-folder" { + let mut choices = div().flex().flex_col().gap_1(); + for (id, name) in std::iter::once((String::new(), "Unfiled".to_string())).chain( + ws.cloud + .folders + .iter() + .map(|f| (f.id.clone(), f.name.clone())), + ) { + let display = format!("{} {}", if id == committed { "●" } else { "○" }, name); + choices = choices.child(ui::button( + display, + false, + move |ws, _, cx| { + ws.update_modal(|modal| { + if let Modal::Cloud { fields, .. } = modal { + if let Some((_, _, v)) = + fields.iter_mut().find(|(k, _, _)| *k == "cloud-folder") + { + *v = id.clone(); + } + } + }); + cx.notify(); + }, + cx, + )); + } + body = body.child(ui::field_row("Folder", choices)); + continue; + } + let active = ws.focused_field == Some(key); + let shown = if active { + ws.field_buffer.clone() + } else { + committed.clone() + }; + let value = if active { + let at = ws.field_cursor.min(shown.len()); + ui::caret_run( + shown[..at].to_string(), + shown[at..].to_string(), + ws.caret_on(), + ui::palette().text, + ) + .into_any_element() + } else { + div().child(shown).into_any_element() + }; + body = body.child(ui::field_row( + label, + div() + .w(px(270.0)) + .min_h(px(24.0)) + .px_1() + .bg(gpui::rgb(ui::palette().field_bg)) + .border_1() + .border_color(gpui::rgb(if active { + ui::palette().accent + } else { + ui::palette().field_bg + })) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |ws, _, _, cx| { + ws.focus_field(key, committed.clone()); + cx.notify(); + }), + ) + .child(value), + )); + } + let actions = div() + .flex() + .gap_2() + .child(ui::button( + "Cancel", + false, + |ws, _, cx| ws.close_modal(cx), + cx, + )) + .child(ui::button( + if kind == "sign-in" { + "Continue in browser" + } else if kind == "download" { + "Download…" + } else if kind == "move-items" { + "Move" + } else if kind == "upload-folder" { + "Upload" + } else if kind.starts_with("delete-") { + "Delete" + } else { + "Apply" + }, + true, + move |ws, _, cx| { + ws.commit_focused_field(); + let Some(Modal::Cloud { fields, .. }) = ws.modal.clone() else { + return; + }; + match ws.cloud_submit(kind, fields, cx) { + Ok(()) => ws.close_modal(cx), + Err(e) => { + ws.status = e.to_string().into(); + ws.cloud.message = e.to_string(); + cx.notify(); + } + } + }, + cx, + )); + ui::modal_frame(title, 620.0, body, actions).into_any_element() +} + +/// The browser's gallery: the cloud room on its own, since the web has +/// no watched folders. The same strip, sidebar, grid and tray. +#[cfg(target_arch = "wasm32")] +pub(super) fn browser_gallery(ws: &mut Workspace, cx: &mut Context) -> gpui::AnyElement { + let context_menu = context_menu(ws, cx); + let sidebar = sidebar_column("cloud-sidebar") + .child(group_chips(ws.gallery_group_by(), &GroupBy::ALL, cx)) + .child(sidebar_caption("FOLDERS")) + .children(folder_rows(ws, cx)) + .child(sidebar_link( + "+ New folder…", + |ws, _w, cx| new_cloud_folder(ws, cx), + cx, + )) + .child(sidebar_caption("BUCKETS")) + .children(bucket_rows(ws, cx)) + .children(super::cloud_people::rows(ws, true, cx)) + .child(sidebar_link( + "+ New bucket…", + |ws, _w, cx| new_cloud_bucket(ws, cx), + cx, + )); + let root = div() + .flex() + .flex_col() + .flex_grow() + .min_h(px(0.0)) + .bg(gpui::rgb(pal().grid_bg)) + .text_color(gpui::rgb(pal().text)) + .track_focus(&ws.focus) + .on_key_down(cx.listener(|ws, ev: &gpui::KeyDownEvent, window, cx| { + if ws.modal.is_some() { + if ev.keystroke.key == "enter" { + ws.commit_focused_field(); + ws.confirm_modal(window, cx); + } else { + ws.field_key(&ev.keystroke.key, ev.keystroke.key_char.as_deref()); + } + cx.notify(); + cx.stop_propagation(); + return; + } + if ws.gallery_key(ev, cx) { + cx.stop_propagation(); + } + })) + .child(chrome::top_strip(ws, cx)) + .children( + (ws.cloud.account.is_none() && ws.cloud.message != "Not signed in").then(|| { + div() + .px_3() + .py_2() + .text_size(px(12.0)) + .child(ws.cloud.message.clone()) + }), + ) + .child( + div() + .flex() + .flex_row() + .flex_grow() + .min_h(px(0.0)) + .child(sidebar) + .child(grid(ws, cx)), + ) + .child(chrome::tray(ws, cx)) + .children(context_menu) + .into_any_element(); + ws.cloud_reveal_tick(cx); + root +} + +#[cfg(test)] +mod grouping_tests { + use super::*; + + #[test] + fn places_match_local_ordering_with_unlocated_photos_last() { + let mut older = asset("older", None, Some(10), 100); + older.place_name = Some("New York City".into()); + let mut newer = asset("newer", None, Some(20), 50); + newer.place_name = older.place_name.clone(); + let mut tokyo = asset("tokyo", None, None, 30); + tokyo.place_name = Some("Tokyo".into()); + let assets = [asset("missing", None, None, 40), tokyo, older, newer]; + let groups = group_assets(&assets, &[], &[], &Scope::Library, "", GroupBy::Place); + assert_eq!( + groups.iter().map(|g| g.0.as_str()).collect::>(), + ["New York City", "Tokyo", "No location"] + ); + assert_eq!( + groups[0] + .2 + .iter() + .map(|a| a.id.as_str()) + .collect::>(), + ["newer", "older"] + ); + } + + fn asset(id: &str, folder: Option<&str>, captured: Option, modified: u64) -> Asset { + Asset { + faces: Vec::new(), + moderation: None, + id: id.into(), + folder_id: folder.map(str::to_string), + name: format!("{id}.jpg"), + mime_type: "image/jpeg".into(), + revision: 1, + size: 1, + edited: false, + tags: vec![], + rating: 0, + captured_at: captured, + modified_at: modified, + thumbnail_url: None, + place_name: None, + location: None, + } + } + fn folder(id: &str, parent: Option<&str>, name: &str) -> Folder { + Folder { + id: id.into(), + parent_id: parent.map(str::to_string), + name: name.into(), + revision: 1, + asset_count: Some(0), + } + } + const MARCH_2024: u64 = 1_710_504_000; + const APRIL_2024: u64 = 1_713_096_000; + const JAN_2023: u64 = 1_673_000_000; + + #[test] + fn months_come_newest_first_with_upload_time_standing_in_for_capture() { + let assets = vec![ + asset("old", None, Some(JAN_2023), APRIL_2024), + asset("march-a", None, Some(MARCH_2024), 0), + asset("undated-april", None, None, APRIL_2024), + asset("march-b", None, Some(MARCH_2024 + 60), 0), + ]; + let groups = group_assets(&assets, &[], &[], &Scope::Library, "", GroupBy::Date); + let titles: Vec<&str> = groups.iter().map(|(t, _, _)| t.as_str()).collect(); + assert_eq!(titles, ["April 2024", "March 2024", "January 2023"]); + let march: Vec<&str> = groups[1].2.iter().map(|a| a.id.as_str()).collect(); + assert_eq!(march, ["march-b", "march-a"], "newest first inside a month"); + assert_eq!(groups[0].2[0].id, "undated-april"); + } + + #[test] + fn folders_group_by_name_with_their_path_and_the_unfiled_last() { + let folders = vec![ + folder("root", None, "Trips"), + folder("child", Some("root"), "Alps"), + folder("zoo", None, "Zoo"), + ]; + let assets = vec![ + asset("z", Some("zoo"), None, 1), + asset("loose", None, None, 1), + asset("a", Some("child"), None, 1), + asset("gone", Some("missing"), None, 1), + ]; + let groups = group_assets(&assets, &folders, &[], &Scope::Library, "", GroupBy::Folder); + let heads: Vec<(&str, &str)> = groups + .iter() + .map(|(t, s, _)| (t.as_str(), s.as_str())) + .collect(); + assert_eq!( + heads, + [ + ("Alps", "Trips / Alps"), + ("Folder", ""), + ("Zoo", "Zoo"), + ("Unfiled", ""), + ] + ); + } + + #[test] + fn a_bucket_or_a_search_is_one_strip() { + let buckets = vec![Bucket { + id: "b".into(), + name: "Summer".into(), + revision: 1, + asset_count: Some(2), + rule: Some(Rule { + scope: Scope::Library, + text: "beach".into(), + filters: Filters { + min_rating: Some(3), + ..Default::default() + }, + }), + }]; + let assets = vec![ + asset("x", None, Some(JAN_2023), 0), + asset("y", None, Some(APRIL_2024), 0), + ]; + let scope = Scope::Bucket { id: "b".into() }; + let groups = group_assets(&assets, &[], &buckets, &scope, "", GroupBy::Date); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].0, "Bucket · Summer"); + assert_eq!(groups[0].1, "\u{201c}beach\u{201d} · 1 filter"); + assert_eq!(groups[0].2.len(), 2, "the provider's order is kept"); + let folders = vec![folder("f", None, "Trips")]; + let scope = Scope::Folder { + id: "f".into(), + recursive: true, + }; + let groups = group_assets(&assets, &folders, &[], &scope, "sun", GroupBy::Folder); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].0, "Trips · Search results"); + } + + #[test] + fn the_sidebar_tree_nests_children_sorts_siblings_and_keeps_orphans() { + let folders = vec![ + folder("b", None, "Beta"), + folder("a", None, "Alpha"), + folder("a2", Some("a"), "Zed"), + folder("a1", Some("a"), "Apple"), + folder("lost", Some("off-page"), "Lost"), + ]; + let tree = folder_tree(&folders); + let tree: Vec<(usize, &str)> = tree.iter().map(|(d, f)| (*d, f.name.as_str())).collect(); + assert_eq!( + tree, + [ + (0, "Alpha"), + (1, "Apple"), + (1, "Zed"), + (0, "Beta"), + (0, "Lost") + ] + ); + } +} diff --git a/crates/app/src/workspace/commands.rs b/crates/app/src/workspace/commands.rs index 40b9811d..79c6b5de 100644 --- a/crates/app/src/workspace/commands.rs +++ b/crates/app/src/workspace/commands.rs @@ -27,6 +27,7 @@ impl Workspace { pub fn after_change(&mut self, cx: &mut Context) { self.refresh_layer_styles(); + self.cloud_capture_edit(); if let Some(doc) = &mut self.doc { let damage = doc.take_damage(); for rect in &damage { @@ -50,6 +51,10 @@ impl Workspace { } pub fn run_command(&mut self, id: &str, cx: &mut Context) { + if (id == "edit.undo" || id == "edit.redo") && self.cloud_undo(id == "edit.redo", cx) { + return; + } + // Grow, Similar and Color Range take their tolerance from the // magic wand, exactly as Photoshop does. self.sync_wand_tolerance(); diff --git a/crates/app/src/workspace/docs.rs b/crates/app/src/workspace/docs.rs index 21779ec6..5861e2aa 100644 --- a/crates/app/src/workspace/docs.rs +++ b/crates/app/src/workspace/docs.rs @@ -100,6 +100,7 @@ impl Workspace { } pub(super) fn open_in_tab(&mut self, mut doc: Document, replace_pristine: bool) { + self.cloud.show = false; // A document arriving is what ends the gallery: whether it came // from File ▸ New, a gallery double-click or a crash recovery, // the editor is where it lives — and its memory goes with it. @@ -298,6 +299,7 @@ impl Workspace { self.remove_recovery_for(doc.id); #[cfg(not(target_arch = "wasm32"))] self.forget_backing(doc.id); + self.cloud_close_document(doc.id); } if self.background_tabs.is_empty() { self.active_tab = 0; @@ -319,6 +321,7 @@ impl Workspace { self.remove_recovery_for(tab.doc.id); #[cfg(not(target_arch = "wasm32"))] self.forget_backing(tab.doc.id); + self.cloud_close_document(tab.doc.id); if index < self.active_tab { self.active_tab -= 1; } @@ -721,6 +724,9 @@ impl Workspace { /// ⌘S: save over the document's existing path, or fall back to Save As /// when it has never been saved (or its format can't be written). pub fn save_current(&mut self, window: &mut Window, cx: &mut Context) { + if self.cloud_save(cx) { + return; + } let path = self.doc.as_ref().and_then(|d| d.path.clone()); match path { Some(path) if self.exporter_for(&path).is_some() => self.save_file_as(path, cx), diff --git a/crates/app/src/workspace/gallery_chrome.rs b/crates/app/src/workspace/gallery_chrome.rs new file mode 100644 index 00000000..8f314411 --- /dev/null +++ b/crates/app/src/workspace/gallery_chrome.rs @@ -0,0 +1,1513 @@ +//! The gallery's chrome, shared by the local library and Schist Cloud: +//! the palette, the top strip and bottom tray, the sidebar rows, the +//! grid frame with its scrollbar, the thumbnail cell, the drag ghost +//! and the right-click menu frame. The local gallery and the cloud +//! gallery are the same room — one shows watched folders, the other a +//! remote library — so everything here is what both of them draw +//! with, and the browser build, which has no local gallery, draws the +//! cloud one from the very same parts. +//! +//! It keeps its own palette rather than `ui::palette()` — a photo grid +//! wants quieter, flatter chrome than a panel set — but it follows the +//! theme choice: the light theme gets Picasa's warm white lightbox, the +//! dark theme a Lightroom-grey version of the same room, so opening the +//! gallery from a dark editor is not a flashbang. + +use super::*; +use crate::ui::LineEdit; +use gpui::{img, Animation, AnimationExt as _, StatefulInteractiveElement as _, Transformation}; +use schist_ui::{Button, ButtonColors, ListItem}; + +/// The gallery's chrome colours for one theme. +pub struct GalleryPalette { + /// Behind the thumbnails. + pub grid_bg: u32, + /// The top strip and sidebar. + pub chrome_bg: u32, + pub chrome_edge: u32, + pub tray_bg: u32, + pub sidebar_selected: u32, + /// Folder headers and the add-folder link — Picasa's blue. + pub header: u32, + pub text: u32, + pub text_dim: u32, + pub cell_edge: u32, + /// Cell border under the pointer. + pub cell_hover: u32, + pub select_border: u32, + pub select_fill: u32, + pub button_bg: u32, + pub button_hover: u32, + /// The green action buttons and the "edited" badge. + pub green: u32, + pub green_hover: u32, +} + +/// Picasa: white grid, warm grey chrome. +const GALLERY_LIGHT: GalleryPalette = GalleryPalette { + grid_bg: 0xFFFFFF, + chrome_bg: 0xEDEDE6, + chrome_edge: 0xC9C9C0, + tray_bg: 0xE3E3DC, + sidebar_selected: 0xCFE0F2, + header: 0x2A5DB0, + text: 0x2B2B2B, + text_dim: 0x7A7A72, + cell_edge: 0xDDDDDD, + cell_hover: 0xB9CBE0, + select_border: 0x4A90D9, + select_fill: 0xE8F0FB, + button_bg: 0xF7F7F2, + button_hover: 0xFFFFFF, + green: 0x5C9E31, + green_hover: 0x6DB33F, +}; + +/// The same room with the lights down — Lightroom's greys. +const GALLERY_DARK: GalleryPalette = GalleryPalette { + grid_bg: 0x232323, + chrome_bg: 0x2B2B2B, + chrome_edge: 0x1C1C1C, + tray_bg: 0x282828, + sidebar_selected: 0x3A4A5C, + header: 0x7FB0E8, + text: 0xD8D8D8, + text_dim: 0x8F8F8A, + cell_edge: 0x3A3A3A, + cell_hover: 0x55708C, + select_border: 0x4A90D9, + select_fill: 0x2C3A4A, + button_bg: 0x383838, + button_hover: 0x444444, + green: 0x5C9E31, + green_hover: 0x6DB33F, +}; + +pub fn pal() -> &'static GalleryPalette { + if crate::ui::is_light() { + &GALLERY_LIGHT + } else { + &GALLERY_DARK + } +} + +/// How the grid is grouped. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GroupBy { + /// By capture month, newest first — the diary reading. + Date, + /// By the directory scanning found them in, or the cloud folder. + Folder, + /// By the nearest city their EXIF position names. + Place, +} + +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +impl GroupBy { + pub const ALL: [GroupBy; 3] = [GroupBy::Date, GroupBy::Folder, GroupBy::Place]; + + pub fn label(self) -> &'static str { + match self { + GroupBy::Date => "Date", + GroupBy::Folder => "Folder", + GroupBy::Place => "Place", + } + } + + pub fn key(self) -> &'static str { + match self { + GroupBy::Date => "date", + GroupBy::Folder => "folder", + GroupBy::Place => "place", + } + } + + pub fn from_key(key: &str) -> Option { + GroupBy::ALL.into_iter().find(|g| g.key() == key) + } +} + +pub const MONTHS: [&str; 12] = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +]; + +/// A "YYYY-MM" month key as a header: "March 2024", or "Undated" for +/// anything that is not a month. +pub fn month_title(key: &str) -> String { + match ( + key.get(..4), + key.get(5..7).and_then(|m| m.parse::().ok()), + ) { + (Some(year), Some(month)) if (1..=12).contains(&month) => { + format!("{} {year}", MONTHS[month - 1]) + } + _ => "Undated".to_string(), + } +} + +/// The "YYYY-MM" month of a Unix time, in UTC — the cloud reports +/// capture times as seconds, and a month header does not care about +/// the hour. +pub fn month_key(unix: i64) -> String { + // Civil-from-days (Howard Hinnant), enough for a month header. + let days = unix.div_euclid(86_400); + let z = days + 719_468; + let era = z.div_euclid(146_097); + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + format!("{y:04}-{m:02}") +} + +const SCROLLBAR_INSET: f32 = 4.0; + +/// A grid's scroll handle, plus the viewport and selected-cell +/// rectangles recorded each paint — what keyboard navigation needs to +/// keep the selection on screen in a wrap layout that has no notion of +/// rows to ask about — and the state of a scrollbar-thumb drag. +pub struct GridScroll { + pub handle: gpui::ScrollHandle, + pub bounds: Bounds, + /// A scrollbar-thumb drag in progress: the pointer's offset from + /// the thumb's top when it was grabbed, in pixels. + pub grab: Option, + pub selected_bounds: Option>, + /// The keyboard moved the selection; scroll until it is visible. + pub reveal: bool, +} + +impl Default for GridScroll { + fn default() -> Self { + Self { + handle: gpui::ScrollHandle::new(), + bounds: Bounds::default(), + grab: None, + selected_bounds: None, + reveal: false, + } + } +} + +impl GridScroll { + /// Cells per row at the recorded width: p_2 padding both sides, + /// gap_2 between cells — the keyboard navigation's formula, so + /// rows agree with up/down arrows. + pub fn columns(&self, cell: f32) -> usize { + let width = f32::from(self.bounds.size.width); + (((width - 16.0 + 8.0) / (cell + 8.0)).floor() as usize).max(1) + } + + /// The scrollbar's (inset, thumb height, thumb travel, max scroll) + /// when the content overflows; `None` when it fits. + pub fn scrollbar_geometry(&self) -> Option<(f32, f32, f32, f32)> { + let view_h = f32::from(self.bounds.size.height); + let max_y = f32::from(self.handle.max_offset().height); + if view_h <= 0.0 || max_y <= 1.0 { + return None; + } + let track_h = view_h - 2.0 * SCROLLBAR_INSET; + let thumb_h = (track_h * view_h / (view_h + max_y)).clamp(30.0, track_h); + let travel = (track_h - thumb_h).max(1.0); + Some((SCROLLBAR_INSET, thumb_h, travel, max_y)) + } + + /// Scroll so the thumb follows the pointer of an active grab; + /// `pointer_y` is in window coordinates. + pub fn drag_to(&mut self, pointer_y: f32) { + let Some(grab) = self.grab else { + return; + }; + let Some((inset, _, travel, max_y)) = self.scrollbar_geometry() else { + return; + }; + let top = f32::from(self.bounds.origin.y); + let thumb_top = (pointer_y - top - inset - grab).clamp(0.0, travel); + let mut offset = self.handle.offset(); + offset.y = px(-(thumb_top / travel * max_y)); + self.handle.set_offset(offset); + } + + /// Nudge the grid until the keyboard-moved selection is on screen. + /// Runs per render off the bounds the previous paint recorded, so + /// it converges a frame after the selection moves. Returns whether + /// it scrolled. + pub fn reveal_tick(&mut self) -> bool { + if !self.reveal { + return false; + } + let (Some(cell), view) = (self.selected_bounds, self.bounds) else { + return false; + }; + if view.size.height <= px(0.0) { + return false; + } + let top = f32::from(cell.origin.y); + let bottom = top + f32::from(cell.size.height); + let view_top = f32::from(view.origin.y); + let view_bottom = view_top + f32::from(view.size.height); + let mut offset = self.handle.offset(); + if bottom > view_bottom { + // Scrolling down means a more negative offset in gpui. + offset.y -= px(bottom - view_bottom + 8.0); + } else if top < view_top { + offset.y += px(view_top - top + 8.0); + } else { + self.reveal = false; + return false; + } + self.handle.set_offset(offset); + true + } +} + +/// Which grid a shared element is working on. A plain function +/// pointer, so the local grid's and the cloud grid's frames can share +/// one implementation without either capturing anything. +pub type GridAccess = fn(&mut Workspace) -> &mut GridScroll; + +pub fn gallery_button( + label: impl Into, + green: bool, + on_click: impl Fn(&mut Workspace, &mut Window, &mut Context) + 'static, + cx: &mut Context, +) -> impl IntoElement { + let label = label.into(); + Button::new(label.clone(), label) + .colors(ButtonColors { + bg: Some(if green { pal().green } else { pal().button_bg }), + hover: if green { + pal().green_hover + } else { + pal().button_hover + }, + text: if green { 0xFFFFFF } else { pal().text }, + border: Some(if green { + pal().green + } else { + pal().chrome_edge + }), + }) + .rounded_md() + .on_click(cx.listener(move |ws, _e, window, cx| on_click(ws, window, cx))) +} + +/// A chip in the top strip that announces an active filter — the +/// least ignorable thing in the strip, since a filter you forgot is a +/// gallery that looks mysteriously empty. Clicking it opens the +/// filter; the ✕ clears it. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub fn filter_chip( + label: String, + open: impl Fn(&mut Workspace, &mut Context) + 'static, + clear: impl Fn(&mut Workspace, &mut Context) + 'static, + cx: &mut Context, +) -> impl IntoElement { + div() + .flex() + .flex_row() + .items_center() + .gap_1() + .h(px(24.0)) + .px_2() + .rounded_md() + .bg(gpui::rgb(pal().select_border)) + .text_color(gpui::rgb(0xFFFFFF)) + .text_size(px(12.0)) + .cursor_pointer() + .on_mouse_down( + MouseButton::Left, + cx.listener(move |ws, _e: &MouseDownEvent, _w, cx| open(ws, cx)), + ) + .child(label) + .child( + div() + .px_1() + .hover(|s| s.bg(gpui::rgb(0xFFFFFF30)).rounded_sm()) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |ws, _e: &MouseDownEvent, _w, cx| { + cx.stop_propagation(); + clear(ws, cx); + }), + ) + .child("\u{2715}"), + ) +} + +/// The search box: a [`LineEdit`] drawn the gallery's way. Takes the +/// keyboard while active (the key context flips to text entry, so +/// letters stop being tool shortcuts). `focus` runs on click, `clear` +/// on the ✕ that appears once there is text. +pub fn search_field( + edit: &LineEdit, + placeholder: SharedString, + caret_on: bool, + focus: impl Fn(&mut Workspace, &mut Context) + 'static, + clear: impl Fn(&mut Workspace, &mut Context) + 'static, + cx: &mut Context, +) -> impl IntoElement { + let active = edit.active; + let text = edit.text.clone(); + let cursor = edit.cursor.min(text.len()); + div() + .flex() + .flex_row() + .items_center() + .gap_1() + .w(px(260.0)) + .h(px(24.0)) + .px_2() + .rounded_md() + .bg(gpui::rgb(pal().grid_bg)) + .border_1() + .border_color(gpui::rgb(if active { + pal().select_border + } else { + pal().chrome_edge + })) + .text_size(px(12.0)) + .text_color(gpui::rgb(if text.is_empty() { + pal().text_dim + } else { + pal().text + })) + .cursor(gpui::CursorStyle::IBeam) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |ws, _e: &MouseDownEvent, _w, cx| { + focus(ws, cx); + ws.reset_caret_phase(); + cx.notify(); + }), + ) + .child(div().flex_grow().truncate().child( + // ⌘A's selection, drawn the way every field draws one; a + // focused box otherwise shows a blinking caret the arrows + // move, with the placeholder ghosted while it is empty. + if edit.selected && !text.is_empty() { + div() + .rounded_sm() + .px(px(1.0)) + .bg(gpui::rgb(pal().select_border)) + .text_color(gpui::rgb(0xFFFFFF)) + .child(SharedString::from(text.clone())) + .into_any_element() + } else if active { + div() + .flex() + .flex_row() + .items_center() + .child(crate::ui::caret_run( + text[..cursor].to_string(), + text[cursor..].to_string(), + caret_on, + pal().text, + )) + .children(text.is_empty().then(|| { + div() + .text_color(gpui::rgb(pal().text_dim)) + .child(placeholder.clone()) + })) + .into_any_element() + } else if text.is_empty() { + div().child(placeholder.clone()).into_any_element() + } else { + div() + .child(SharedString::from(text.clone())) + .into_any_element() + }, + )) + .children((!text.is_empty()).then(|| { + div() + .px_1() + .text_color(gpui::rgb(pal().text_dim)) + .hover(|s| s.text_color(gpui::rgb(pal().text))) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |ws, _e: &MouseDownEvent, _w, cx| { + cx.stop_propagation(); + clear(ws, cx); + }), + ) + .child("\u{2715}") + })) +} + +/// The thumbnail-size slider, drawn on the gallery's own palette so it +/// does not import the editor theme's near-black track onto the tray. +pub fn size_slider(ratio: f32, cx: &mut Context) -> impl IntoElement { + const WIDTH: f32 = 110.0; + let entity = cx.entity(); + let set = move |ws: &mut Workspace, r: f32| { + ws.set_gallery_thumb_px(80.0 + r * 160.0); + }; + let down = set; + let moved = set; + div() + .relative() + .w(px(WIDTH)) + .h(px(12.0)) + .flex_none() + .rounded_sm() + .bg(gpui::rgb(pal().chrome_edge)) + .child( + div() + .absolute() + .left_0() + .top_0() + .bottom_0() + .w(px(WIDTH * ratio.clamp(0.0, 1.0))) + .rounded_sm() + .bg(gpui::rgb(pal().select_border)), + ) + .child( + gpui::canvas( + move |bounds, _window, cx| { + entity.update(cx, |ws, _| { + ws.record_slider_bounds("gallery-thumb-size", bounds) + }); + }, + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |ws, ev: &gpui::MouseDownEvent, _w, cx| { + ws.begin_slider("gallery-thumb-size", ratio); + if let Some(r) = ws.slider_ratio("gallery-thumb-size", ev.position) { + down(ws, r); + } + cx.notify(); + }), + ) + .on_mouse_move(cx.listener(move |ws, ev: &gpui::MouseMoveEvent, _w, cx| { + if ev.pressed_button == Some(MouseButton::Left) + && ws.dragging_slider("gallery-thumb-size") + { + if let Some(r) = ws.slider_ratio("gallery-thumb-size", ev.position) { + moved(ws, r); + cx.notify(); + } + } + })) + .on_mouse_up( + MouseButton::Left, + cx.listener(|ws, _ev: &gpui::MouseUpEvent, _w, _cx| { + ws.end_slider("gallery-thumb-size"); + }), + ) +} + +/// A section caption in the sidebar: "FOLDERS", "BUCKETS". +pub fn sidebar_caption(text: impl Into) -> impl IntoElement { + div() + .px_2() + .pt_2() + .pb_1() + .text_size(px(11.0)) + .text_color(gpui::rgb(pal().text_dim)) + .child(text.into()) +} + +/// A blue link row in the sidebar: "+ Add folder…", "+ New bucket". +pub fn sidebar_link( + label: impl Into, + on_click: impl Fn(&mut Workspace, &mut Window, &mut Context) + 'static, + cx: &mut Context, +) -> impl IntoElement { + div() + .px_2() + .h(px(24.0)) + .flex() + .items_center() + .text_size(px(12.0)) + .text_color(gpui::rgb(pal().header)) + .cursor_pointer() + .hover(|s| s.bg(gpui::rgb(pal().sidebar_selected))) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |ws, _e: &MouseDownEvent, window, cx| on_click(ws, window, cx)), + ) + .child(label.into()) +} + +/// A sidebar link whose click also reports the pointer, for one that +/// opens a small menu there. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub fn sidebar_menu_link( + label: impl Into, + on_click: impl Fn(&mut Workspace, Point, &mut Window, &mut Context) + 'static, + cx: &mut Context, +) -> impl IntoElement { + div() + .px_2() + .h(px(24.0)) + .flex() + .items_center() + .text_size(px(12.0)) + .text_color(gpui::rgb(pal().header)) + .cursor_pointer() + .hover(|s| s.bg(gpui::rgb(pal().sidebar_selected))) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |ws, e: &MouseDownEvent, window, cx| { + on_click(ws, e.position, window, cx) + }), + ) + .child(label.into()) +} + +/// One row of the sidebar, before its behaviour: the label, an +/// optional count on the right, the selected tint. Callers add the +/// click, drag and drop. +pub fn sidebar_row_frame( + id: impl Into, + label: impl Into, + count: Option, + selected: bool, + indent: usize, +) -> gpui::Stateful { + div() + .id(id) + .flex() + .flex_row() + .items_center() + .justify_between() + .px_2() + .pl(px(8.0 + 12.0 * indent as f32)) + .h(px(24.0)) + .text_size(px(12.0)) + .cursor_pointer() + .bg(gpui::rgb(if selected { + pal().sidebar_selected + } else { + pal().chrome_bg + })) + .hover(|s| s.bg(gpui::rgb(pal().sidebar_selected))) + .child(div().flex_grow().truncate().child(label.into())) + .children(count.map(|count| { + div() + .text_size(px(10.0)) + .text_color(gpui::rgb(pal().text_dim)) + .child(format!("{count}")) + })) +} + +/// The GROUP BY chips at the top of the sidebar. +pub fn group_chips( + current: GroupBy, + options: &[GroupBy], + cx: &mut Context, +) -> impl IntoElement { + let mut row = div().flex().flex_row().gap_1().px_2().pb_2(); + for &group in options { + let active = group == current; + row = row.child( + div() + .px_2() + .h(px(20.0)) + .flex() + .items_center() + .rounded_md() + .text_size(px(11.0)) + .cursor_pointer() + .bg(gpui::rgb(if active { + pal().sidebar_selected + } else { + pal().button_bg + })) + .hover(move |s| { + if active { + s + } else { + s.bg(gpui::rgb(pal().button_hover)) + } + }) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |ws, _e: &MouseDownEvent, _w, cx| { + ws.set_gallery_group(group, cx); + }), + ) + .child(group.label()), + ); + } + div().child(sidebar_caption("GROUP BY")).child(row) +} + +/// The sidebar's column: fixed width, its own scroll, the chrome tint. +pub fn sidebar_column(id: &'static str) -> gpui::Stateful { + div() + .id(id) + .flex() + .flex_col() + .w(px(210.0)) + .flex_none() + .overflow_y_scroll() + .bg(gpui::rgb(pal().chrome_bg)) + .border_r_1() + .border_color(gpui::rgb(pal().chrome_edge)) +} + +/// A group header in the grid: the blue title, a dim detail, a rule. +pub fn section_header(title: String, detail: String) -> impl IntoElement { + div() + .flex() + .flex_col() + .child( + div() + .flex() + .flex_row() + .items_center() + .gap_2() + .pt_2() + .pb_1() + .child( + div() + .text_size(px(13.0)) + .text_color(gpui::rgb(pal().header)) + .child(title), + ) + .child( + div() + .text_size(px(10.0)) + .text_color(gpui::rgb(pal().text_dim)) + .child(detail), + ), + ) + .child(div().h(px(1.0)).mb_2().bg(gpui::rgb(pal().cell_edge))) +} + +/// The same small activity indicator for the grid, sidebar, and tray. +pub fn loading_spinner(id: &'static str) -> impl IntoElement { + gpui::svg() + .path("icons/loading.svg") + .size(px(14.0)) + .flex_none() + .text_color(gpui::rgb(pal().header)) + .with_animation( + id, + Animation::new(std::time::Duration::from_millis(900)).repeat(), + |icon, delta| icon.with_transformation(Transformation::rotate(gpui::percentage(delta))), + ) +} + +pub fn loading_note() -> impl IntoElement { + div() + .p_4() + .flex() + .items_center() + .gap_2() + .text_size(px(12.0)) + .text_color(gpui::rgb(pal().text_dim)) + .child(loading_spinner("cloud-grid-loading")) + .child("Loading photos…") +} + +/// Why the grid is bare, in the grid's own quiet voice. +pub fn empty_note(text: impl Into) -> impl IntoElement { + div() + .p_4() + .text_size(px(12.0)) + .text_color(gpui::rgb(pal().text_dim)) + .child(text.into()) +} + +/// The scrolling column the grid's rows go in. It records the viewport +/// rectangle, so keyboard navigation can work out columns per row, the +/// reveal logic can keep the selection on screen, and the cells' +/// visibility probes know what "on screen" means; and it takes ⌘-wheel +/// (Ctrl elsewhere) to resize the thumbnails, as ⌘-wheel zooms a +/// canvas. +pub fn grid_column( + id: &'static str, + scroll: &GridScroll, + access: GridAccess, + cx: &mut Context, +) -> gpui::Stateful { + let grid_entity = cx.entity(); + div() + .id(id) + .flex() + .flex_col() + .flex_grow() + .min_h(px(0.0)) + .overflow_y_scroll() + .track_scroll(&scroll.handle) + .bg(gpui::rgb(pal().grid_bg)) + .p_2() + // The canvas sits inside the scrolled content, so its bounds + // scroll along with it — subtract the scroll offset to get + // back to window coordinates, the space every cell's own + // bounds are reported in. + .child( + canvas( + { + let grid_entity = grid_entity.clone(); + move |bounds, _window, cx| { + grid_entity.update(cx, |ws, _| { + let grid = access(ws); + let offset = grid.handle.offset(); + grid.bounds = gpui::Bounds { + origin: bounds.origin - offset, + size: bounds.size, + }; + }); + } + }, + move |_, _, window, _| { + // It has to win over the container's own scrolling, + // which runs in the bubble phase — so take it in + // capture and stop it there. + let grid_entity = grid_entity.clone(); + window.on_mouse_event(move |ev: &gpui::ScrollWheelEvent, phase, _w, cx| { + if phase != gpui::DispatchPhase::Capture + || !(ev.modifiers.platform || ev.modifiers.control) + { + return; + } + let dy = wheel_pixels(ev); + let took = grid_entity.update(cx, |ws, cx| { + if !access(ws).bounds.contains(&ev.position) { + return false; + } + ws.nudge_gallery_thumb_px(dy); + cx.notify(); + true + }); + if took { + cx.stop_propagation(); + } + }); + }, + ) + .absolute() + .size_full(), + ) +} + +/// A wheel event's vertical travel in pixels: lines become pixels at +/// the platform's usual line height. +pub fn wheel_pixels(ev: &gpui::ScrollWheelEvent) -> f32 { + match ev.delta { + gpui::ScrollDelta::Pixels(p) => f32::from(p.y), + gpui::ScrollDelta::Lines(l) => l.y * 20.0, + } +} + +/// The grid's frame around its column: the scrollbar gpui doesn't +/// paint — a track along the viewport's right edge, exact because the +/// thumb reads the scroll handle's own extents. Clicking the track +/// jumps there; dragging is handled by the wrapper, so the pointer may +/// wander off the twelve-pixel strip mid-drag without dropping the +/// thumb. +pub fn grid_frame( + column: impl IntoElement, + scroll: &GridScroll, + access: GridAccess, + cx: &mut Context, +) -> impl IntoElement { + let scrollbar = scroll + .scrollbar_geometry() + .map(|(inset, thumb_h, travel, max_y)| { + let scroll_y = (-f32::from(scroll.handle.offset().y)).clamp(0.0, max_y); + let thumb_top = inset + scroll_y / max_y * travel; + div() + .absolute() + .top_0() + .right_0() + .bottom_0() + .w(px(12.0)) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |ws, ev: &MouseDownEvent, _w, cx| { + let grid = access(ws); + let y = f32::from(ev.position.y) - f32::from(grid.bounds.origin.y); + grid.grab = Some(if (thumb_top..thumb_top + thumb_h).contains(&y) { + // Grabbed the thumb: keep the grip point. + y - thumb_top + } else { + // Clicked the track: the thumb jumps + // there, held by its middle. + thumb_h / 2.0 + }); + grid.drag_to(f32::from(ev.position.y)); + cx.stop_propagation(); + cx.notify(); + }), + ) + .child( + div() + .absolute() + .top(px(thumb_top)) + .right(px(2.0)) + .w(px(8.0)) + .h(px(thumb_h)) + .rounded_md() + .bg(gpui::rgb(pal().cell_edge)) + .hover(|s| s.bg(gpui::rgb(pal().cell_hover))), + ) + }); + div() + .relative() + .flex() + .flex_col() + .flex_grow() + .min_h(px(0.0)) + // Shrinkable: a flex item's minimum width is its content's, and + // a row of cells is a fixed width, so without this the grid + // refuses to give up room to the AI panel beside it and pushes + // it off the right edge. The column count follows the width + // the next frame. + .min_w(px(0.0)) + .overflow_hidden() + .on_mouse_move(cx.listener(move |ws, ev: &gpui::MouseMoveEvent, _w, cx| { + let grid = access(ws); + if grid.grab.is_none() { + return; + } + if ev.pressed_button == Some(MouseButton::Left) { + grid.drag_to(f32::from(ev.position.y)); + cx.notify(); + } else { + // The button went up somewhere we never heard about. + grid.grab = None; + } + })) + .on_mouse_up( + MouseButton::Left, + cx.listener(move |ws, _ev: &gpui::MouseUpEvent, _w, _cx| { + access(ws).grab = None; + }), + ) + .child(column) + .children(scrollbar) +} + +/// One thumbnail's square, before its behaviour: the picture (or the +/// "no preview" note), the selection tint and border, the "edited" +/// badge. Callers add the listeners, the drag and their probes. +pub fn cell_frame( + id: impl Into, + cell: f32, + selected: bool, + thumb: Option>, + failed: bool, + edited: bool, +) -> gpui::Stateful { + let inner = cell - 10.0; + div() + .id(id) + .flex() + .flex_col() + .items_center() + .justify_center() + .w(px(cell)) + .h(px(cell)) + .flex_none() + .relative() + .rounded_sm() + .bg(gpui::rgb(if selected { + pal().select_fill + } else { + pal().grid_bg + })) + .border_2() + .border_color(gpui::rgb(if selected { + pal().select_border + } else { + pal().cell_edge + })) + .cursor_pointer() + .hover(move |s| { + if selected { + s + } else { + s.border_color(gpui::rgb(pal().cell_hover)) + } + }) + .children(thumb.map(|t| img(t).max_w(px(inner)).max_h(px(inner)))) + .children(failed.then(|| { + div() + .text_size(px(10.0)) + .text_color(gpui::rgb(pal().text_dim)) + .child("no preview") + })) + .children(edited.then(|| { + // Picasa's little brush: a corner badge saying this photo + // carries an edit. + div() + .absolute() + .bottom(px(3.0)) + .left(px(3.0)) + .px_1() + .rounded_sm() + .bg(gpui::rgb(pal().green)) + .text_size(px(9.0)) + .text_color(gpui::rgb(0xFFFFFF)) + .child("edited") + })) +} + +/// The lead cell reports where it landed, for the keyboard's +/// scroll-into-view. +pub fn lead_probe(access: GridAccess, cx: &mut Context) -> impl IntoElement { + let cell_entity = cx.entity(); + canvas( + move |bounds, _window, cx| { + cell_entity.update(cx, |ws, _| access(ws).selected_bounds = Some(bounds)); + }, + |_, _, _, _| {}, + ) + .absolute() + .size_full() +} + +/// The ghost that rides the pointer during a drag: the picked-up +/// photo's whole square when its thumbnail is in memory (with a count +/// badge for a multi-drag), the old name pill only when it is not. +pub struct DragGhost { + pub label: String, + pub thumb: Option>, + pub count: usize, + pub size: f32, +} + +impl gpui::Render for DragGhost { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let Some(thumb) = self.thumb.clone() else { + return div() + .px_2() + .py_1() + .rounded_md() + .bg(gpui::rgb(pal().select_border)) + .text_color(gpui::rgb(0xFFFFFF)) + .text_size(px(11.0)) + .child(SharedString::from(self.label.clone())) + .into_any_element(); + }; + let inner = self.size - 10.0; + div() + .w(px(self.size)) + .h(px(self.size)) + .relative() + .flex() + .items_center() + .justify_center() + .rounded_sm() + .bg(gpui::rgb(pal().grid_bg)) + .border_2() + .border_color(gpui::rgb(pal().select_border)) + .opacity(0.85) + .child(img(thumb).max_w(px(inner)).max_h(px(inner))) + .children((self.count > 1).then(|| { + div() + .absolute() + .top(px(-6.0)) + .right(px(-6.0)) + .min_w(px(18.0)) + .h(px(18.0)) + .px_1() + .flex() + .items_center() + .justify_center() + .rounded_full() + .bg(gpui::rgb(pal().select_border)) + .text_color(gpui::rgb(0xFFFFFF)) + .text_size(px(10.0)) + .child(format!("{}", self.count)) + })) + .into_any_element() + } +} + +/// What a menu row does when clicked. +pub type MenuAction = std::rc::Rc)>; + +/// One row of a right-click menu. `dismiss` closes the menu before the +/// action runs. +pub fn menu_row( + label: String, + dismiss: fn(&mut Workspace), + act: MenuAction, + cx: &mut Context, +) -> gpui::AnyElement { + ListItem::new(SharedString::from(format!("menu-row-{label}"))) + .accent_hover() + .on_click(cx.listener(move |ws, _e, window, cx| { + dismiss(ws); + act(ws, window, cx); + cx.notify(); + })) + .child(SharedString::from(label)) + .into_any_element() +} + +pub fn menu_sep() -> gpui::AnyElement { + div() + .h(px(1.0)) + .my_1() + .bg(gpui::rgb(crate::ui::palette().edge)) + .into_any_element() +} + +/// The menu's popup at the pointer, over everything; a click anywhere +/// else dismisses it. +pub fn menu_frame( + position: Point, + rows: Vec, + dismiss: fn(&mut Workspace), + cx: &mut Context, +) -> gpui::AnyElement { + gpui::deferred( + div() + .absolute() + .left(position.x) + .top(position.y) + .w(px(220.0)) + .py_1() + .bg(gpui::rgb(crate::ui::palette().popup_bg)) + .text_color(gpui::rgb(crate::ui::palette().text)) + .border_1() + .border_color(gpui::rgb(crate::ui::palette().edge)) + .rounded_sm() + .shadow_lg() + .occlude() + .on_mouse_down_out(cx.listener(move |ws, _e, _w, cx| { + dismiss(ws); + cx.notify(); + })) + .children(rows), + ) + .into_any_element() +} + +/// The same toolbar for local and cloud photos; only the action's destination changes. +pub fn top_strip(ws: &mut Workspace, cx: &mut Context) -> impl IntoElement { + let has_doc = ws.doc.is_some(); + let cloud = ws.cloud.show; + let strip = div() + .flex() + .flex_row() + .items_center() + .gap_2() + .h(px(38.0)) + .flex_none() + .px_2() + .bg(gpui::rgb(pal().chrome_bg)) + .border_b_1() + .border_color(gpui::rgb(pal().chrome_edge)) + .child(gallery_button( + "Import…", + true, + |ws, _w, cx| { + if ws.cloud.show { + ws.cloud_pick_upload(false, cx); + } + #[cfg(not(target_arch = "wasm32"))] + if !ws.cloud.show { + ws.gallery_import_camera(cx); + } + }, + cx, + )) + .child(gallery_button( + "Add Folder…", + false, + |ws, window, cx| { + if ws.cloud.show { + ws.cloud_pick_upload(true, cx); + } + #[cfg(not(target_arch = "wasm32"))] + if !ws.cloud.show { + ws.gallery_add_folder(window, cx); + } + #[cfg(target_arch = "wasm32")] + let _ = window; + }, + cx, + )) + .child(gallery_button( + "Refresh", + false, + |ws, _w, cx| { + if ws.cloud.show { + ws.cloud_refresh(cx); + } + #[cfg(not(target_arch = "wasm32"))] + if !ws.cloud.show { + ws.library_rescan(cx); + } + }, + cx, + )) + .child(div().flex_grow()); + let strip = if cloud { + strip + .children(super::cloud_view::filter_chip(ws, cx)) + .child(super::cloud_view::search_box(ws, cx)) + } else { + #[cfg(not(target_arch = "wasm32"))] + { + super::library_view::local_strip_search(strip, ws, cx) + } + #[cfg(target_arch = "wasm32")] + { + strip + } + }; + strip + .child(div().flex_grow()) + .child(gallery_button( + "Settings…", + false, + |ws, _w, cx| { + ws.snapshot_preferences(); + ws.open_modal(Modal::Preferences, cx); + }, + cx, + )) + .child(gallery_button( + "Open…", + false, + crate::keymap::open_file_dialog, + cx, + )) + .child(gallery_button( + "New File…", + false, + |ws, _w, cx| ws.open_new_file_picker(cx), + cx, + )) + .children((has_doc || cfg!(target_arch = "wasm32")).then(|| { + gallery_button( + "Back to Editing", + false, + |ws, _w, cx| ws.gallery_back_to_editor(cx), + cx, + ) + })) +} + +pub fn photo_count(count: usize) -> String { + format!("{count} {}", if count == 1 { "photo" } else { "photos" }) +} + +/// What a tray button does. +pub type TrayAction = Box)>; + +/// What the tray says about the current selection and library, in +/// either room. +pub struct TrayInfo { + /// The green Edit button's action, when one photo leads. + pub edit: Option, + /// A second button beside Edit — the cloud's Download…. + pub extra: Option<(&'static str, TrayAction)>, + pub name: Option, + pub selected: usize, + /// Dim remarks: "edited — versions kept beside the file", "3 hidden + /// by the content filter". + pub notes: Vec, + /// "128 photos". + pub count: String, +} + +/// The bottom tray: selection details and the green Edit button on the +/// left, the photo count and size slider on the right. +pub fn tray(ws: &mut Workspace, cx: &mut Context) -> impl IntoElement { + let info = if ws.cloud.show { + super::cloud_view::tray_info(ws) + } else { + #[cfg(not(target_arch = "wasm32"))] + { + super::library_view::tray_info(ws) + } + #[cfg(target_arch = "wasm32")] + { + super::cloud_view::tray_info(ws) + } + }; + let thumb_px = ws.gallery_thumb_px(); + let ratio = (thumb_px - 80.0) / 160.0; + div() + .id("gallery-tray") + .flex() + .flex_row() + .items_center() + .gap_3() + .h(px(40.0)) + .flex_none() + .px_2() + .bg(gpui::rgb(pal().tray_bg)) + .border_t_1() + .border_color(gpui::rgb(pal().chrome_edge)) + .children( + info.edit + .map(|edit| gallery_button("Edit", true, move |ws, w, cx| edit(ws, w, cx), cx)), + ) + .children( + info.extra.map(|(label, act)| { + gallery_button(label, false, move |ws, w, cx| act(ws, w, cx), cx) + }), + ) + .children(info.name.map(|name| { + div() + .text_size(px(12.0)) + .text_color(gpui::rgb(pal().text)) + .child(name) + })) + .children((info.selected > 1).then(|| { + div() + .text_size(px(11.0)) + .text_color(gpui::rgb(pal().text_dim)) + .child(format!("{} selected", info.selected)) + })) + .children(info.notes.into_iter().map(|note| { + div() + .text_size(px(11.0)) + .text_color(gpui::rgb(pal().text_dim)) + .child(note) + })) + .child(div().flex_grow()) + // The editor's status bar is hidden here, so the tray carries the + // status line — otherwise an import's outcome lands nowhere. A + // cloud transfer under way shows as a bar instead. + .child(match ws.cloud.progress.clone() { + Some((done, total, label)) => { + let ratio = if total == 0 { + 0.0 + } else { + (done as f32 / total as f32).clamp(0.0, 1.0) + }; + div() + .flex() + .flex_col() + .justify_center() + .gap_1() + .w(px(300.0)) + .child( + div() + .truncate() + .text_size(px(11.0)) + .text_color(gpui::rgb(pal().text_dim)) + .child(label), + ) + .child( + div() + .w_full() + .h(px(4.0)) + .rounded_sm() + .bg(gpui::rgb(pal().chrome_edge)) + .child( + div() + .h_full() + .w(gpui::relative(ratio)) + .rounded_sm() + .bg(gpui::rgb(pal().select_border)), + ), + ) + .into_any_element() + } + None => div() + .max_w(px(420.0)) + .truncate() + .text_size(px(11.0)) + .text_color(gpui::rgb(pal().text_dim)) + .child(ws.status.clone()) + .into_any_element(), + }) + .child( + div() + .flex() + .items_center() + .gap_2() + .text_size(px(11.0)) + .text_color(gpui::rgb(pal().text_dim)) + .children( + (ws.cloud.show && ws.cloud.is_loading()) + .then(|| loading_spinner("cloud-count-loading")), + ) + .child(info.count), + ) + .child(size_slider(ratio, cx)) +} + +impl Workspace { + /// Thumbnail cell edge in pixels, the tray slider's value. One + /// setting for both rooms on desktop; the browser has only the + /// cloud's. + pub(crate) fn gallery_thumb_px(&self) -> f32 { + #[cfg(not(target_arch = "wasm32"))] + { + self.library.thumb_px + } + #[cfg(target_arch = "wasm32")] + { + self.cloud.thumb_px + } + } + + /// One grouping choice for both local and cloud photos. + pub(crate) fn gallery_group_by(&self) -> GroupBy { + #[cfg(not(target_arch = "wasm32"))] + let group = self.library.group_by; + #[cfg(target_arch = "wasm32")] + let group = self.cloud.group_by; + group + } + + /// Whether a gallery search box is taking typing, for the key + /// context. + pub(crate) fn gallery_typing(&self) -> bool { + if !self.gallery_open() { + return false; + } + if self.cloud.show { + return self.cloud.search.active; + } + #[cfg(not(target_arch = "wasm32"))] + { + self.library.search.active || self.focused_field == Some("face-name") + } + #[cfg(target_arch = "wasm32")] + { + false + } + } + + /// A key while the gallery has the keyboard and no dialog is up: + /// the search box first, then the arrows over the grid. + pub(crate) fn gallery_key(&mut self, ev: &gpui::KeyDownEvent, cx: &mut Context) -> bool { + if self.cloud.show { + return self.cloud_search_key(ev, cx) || self.cloud_nav_key(ev, cx); + } + #[cfg(not(target_arch = "wasm32"))] + { + self.gallery_viewer_key(ev, cx) + || self.gallery_search_key(ev, cx) + || self.gallery_nav_key(ev, cx) + } + #[cfg(target_arch = "wasm32")] + { + false + } + } + + /// Escape in the gallery leaves the search — it is the innermost + /// thing open. Returns whether there was one to leave. + pub(crate) fn gallery_escape(&mut self, cx: &mut Context) -> bool { + if !self.gallery_open() { + return false; + } + if self.cloud.show { + return self.cloud_search_clear(cx); + } + #[cfg(not(target_arch = "wasm32"))] + { + self.local_gallery_escape(cx) + } + #[cfg(target_arch = "wasm32")] + { + false + } + } + + /// Enter in the gallery opens the selected photo, unless the search + /// box has the keyboard. Returns whether the gallery took it. + pub(crate) fn gallery_enter(&mut self, cx: &mut Context) -> bool { + if !self.gallery_open() { + return false; + } + if self.cloud.show { + if self.cloud.search.active { + return false; + } + if let Some(asset) = self.cloud_lead_asset() { + self.cloud_open(asset, cx); + } + return true; + } + #[cfg(not(target_arch = "wasm32"))] + { + if self.library.search.active { + return false; + } + if let Some(path) = self.library.lead_selected().cloned() { + self.open_from_gallery(path, cx); + } + true + } + #[cfg(target_arch = "wasm32")] + { + false + } + } + + /// The strip's "Back to Editing". + pub(crate) fn gallery_back_to_editor(&mut self, cx: &mut Context) { + #[cfg(not(target_arch = "wasm32"))] + { + self.toggle_gallery(cx); + } + #[cfg(target_arch = "wasm32")] + { + self.cloud_set_visible(false); + cx.notify(); + } + } +} + +#[cfg(target_arch = "wasm32")] +impl Workspace { + pub fn set_gallery_thumb_px(&mut self, value: f32) { + self.cloud.thumb_px = value.clamp(80.0, 240.0); + } + + pub fn nudge_gallery_thumb_px(&mut self, wheel_dy: f32) { + let value = self.cloud.thumb_px + wheel_dy * 0.2; + self.set_gallery_thumb_px(value); + } + + pub fn set_gallery_group(&mut self, group: GroupBy, cx: &mut Context) { + self.cloud.group_by = group; + self.cloud.query.offset = 0; + self.cloud.query.sort = self.cloud_sort(); + self.cloud_watch_assets(true); + cx.notify(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn month_keys_and_titles() { + // 2024-03-15T12:00:00Z + assert_eq!(month_key(1_710_504_000), "2024-03"); + // 1970-01-01 + assert_eq!(month_key(0), "1970-01"); + // 1999-12-31T23:59:59Z + assert_eq!(month_key(946_684_799), "1999-12"); + assert_eq!(month_title("2024-03"), "March 2024"); + assert_eq!(month_title("0000-00"), "Undated"); + assert_eq!(month_title("nonsense"), "Undated"); + } +} diff --git a/crates/app/src/workspace/library.rs b/crates/app/src/workspace/library.rs index 734ba88d..c024ba97 100644 --- a/crates/app/src/workspace/library.rs +++ b/crates/app/src/workspace/library.rs @@ -11,6 +11,7 @@ //! folders to watch and no cameras to mount, so the whole module is //! compiled out of the web build. +use super::gallery_chrome::GridScroll; use super::library_geo; use super::*; // The on-disk model — folders, buckets, the index snapshot, the caches @@ -35,7 +36,6 @@ const THUMB_KEEP: usize = 1024; /// the first screenfuls to reappear instantly, a fraction of the RAM. const THUMB_KEEP_PARKED: usize = 256; /// The grid scrollbar's breathing room at each end of its track. -const SCROLLBAR_INSET: f32 = 4.0; /// How many recently opened files the start screen lists. const RECENTS_KEPT: usize = 10; /// Longest edge the viewer decodes a photo at: enough to fill a @@ -134,7 +134,13 @@ pub enum GalleryContext { /// Freeze the clicked marker's members independently of selection or zoom. MapCluster(Vec), Bucket(usize), + /// "+ Add folder…" with a cloud signed in: local or cloud? + AddFolder, + /// "+ New bucket" with a cloud signed in: local or cloud? + NewBucket, Person(usize), + /// A watched local folder's row. + LocalFolder(PathBuf), } /// What the sidebar's People rows show instead of the folders. @@ -386,15 +392,7 @@ pub struct Library { pub map_filter_name: Option, /// The search box: its text, whether it is taking keystrokes, and /// the current query's ranked results (`None` = not searching). - pub search: String, - pub search_active: bool, - /// The caret's byte position in `search`, always on a char - /// boundary — arrows move it, typing inserts at it. - pub search_cursor: usize, - /// ⌘A selected the whole query: the next keystroke replaces it, - /// backspace clears it, ⌘C/⌘X take it — the minimal selection a - /// one-line box owes the keyboard. - pub search_selected: bool, + pub search: crate::ui::LineEdit, pub search_results: Option>, /// The place the current query named, when it named one — shown on /// the results header. @@ -415,6 +413,8 @@ pub struct Library { /// and the generation it last wrote — so the file is read once and /// written only when something new was learned. index_loaded: bool, + /// Try automatic People model installation once per session, with manual retry. + people_models_started: bool, index_saved_gen: u64, /// When the loader last repainted for index-only work: those /// batches finish in milliseconds off warm caches, and notifying @@ -428,73 +428,18 @@ pub struct Library { /// rectangles recorded each paint — what keyboard navigation needs /// to keep the selection on screen in a wrap layout that has no /// notion of rows to ask about. - pub grid_scroll: gpui::ScrollHandle, - pub grid_bounds: Bounds, - /// A scrollbar-thumb drag in progress: the pointer's offset from - /// the thumb's top when it was grabbed, in pixels. - pub scrollbar_grab: Option, + pub grid: GridScroll, /// The photos a gpui drag is currently carrying. Kept so that a /// drag which wanders out of the window can be handed to the /// platform's own drag-and-drop, and dropped on a file manager. pub dragging: Option>, - pub selected_bounds: Option>, - /// The keyboard moved the selection; scroll until it is visible. - reveal_selection: bool, /// A thumbnail failed for want of the HEIC support download; the /// gallery offers it once. heif_needed: Option, heif_prompted: bool, } -/// How the grid is grouped. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum GroupBy { - /// By capture month, newest first — the diary reading. - Date, - /// By the directory scanning found them in. - Folder, - /// By the nearest city their EXIF position names. - Place, -} - -impl GroupBy { - pub const ALL: [GroupBy; 3] = [GroupBy::Date, GroupBy::Folder, GroupBy::Place]; - - pub fn label(self) -> &'static str { - match self { - GroupBy::Date => "Date", - GroupBy::Folder => "Folder", - GroupBy::Place => "Place", - } - } - - pub(super) fn key(self) -> &'static str { - match self { - GroupBy::Date => "date", - GroupBy::Folder => "folder", - GroupBy::Place => "place", - } - } - - pub(super) fn from_key(key: &str) -> Option { - GroupBy::ALL.into_iter().find(|g| g.key() == key) - } -} - -const MONTHS: [&str; 12] = [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", -]; +pub use super::gallery_chrome::GroupBy; impl Library { /// Load the persisted folder list and recents. @@ -597,10 +542,7 @@ impl Library { .unwrap_or(GroupBy::Date), map_filter: None, map_filter_name: None, - search: String::new(), - search_active: false, - search_cursor: 0, - search_selected: false, + search: Default::default(), search_results: None, search_place: None, search_scoped: None, @@ -608,16 +550,13 @@ impl Library { query_cache: FxHashMap::default(), index_gen: 0, index_loaded: false, + people_models_started: false, index_saved_gen: 0, last_loader_notify: None, index_snapshot: None, engine_warmed: false, - grid_scroll: gpui::ScrollHandle::new(), - grid_bounds: Bounds::default(), - scrollbar_grab: None, + grid: GridScroll::default(), dragging: None, - selected_bounds: None, - reveal_selection: false, heif_needed: None, heif_prompted: false, } @@ -731,37 +670,6 @@ impl Library { self.thumb_frame += 1; } - /// The grid scrollbar's geometry: (track inset, thumb height, - /// thumb travel, max scroll), exact because it reads the scroll - /// handle's own extents. `None` while nothing scrolls. - pub(super) fn scrollbar_geometry(&self) -> Option<(f32, f32, f32, f32)> { - let view_h = f32::from(self.grid_bounds.size.height); - let max_y = f32::from(self.grid_scroll.max_offset().height); - if view_h <= 0.0 || max_y <= 1.0 { - return None; - } - let track_h = view_h - 2.0 * SCROLLBAR_INSET; - let thumb_h = (track_h * view_h / (view_h + max_y)).clamp(30.0, track_h); - let travel = (track_h - thumb_h).max(1.0); - Some((SCROLLBAR_INSET, thumb_h, travel, max_y)) - } - - /// Scroll so the thumb follows the pointer of an active grab; - /// `pointer_y` is in window coordinates. - pub(super) fn scrollbar_drag_to(&mut self, pointer_y: f32) { - let Some(grab) = self.scrollbar_grab else { - return; - }; - let Some((inset, _, travel, max_y)) = self.scrollbar_geometry() else { - return; - }; - let top = f32::from(self.grid_bounds.origin.y); - let thumb_top = (pointer_y - top - inset - grab).clamp(0.0, travel); - let mut offset = self.grid_scroll.offset(); - offset.y = px(-(thumb_top / travel * max_y)); - self.grid_scroll.set_offset(offset); - } - /// The gallery left the screen: give back what only it was using. /// The scorer and the two search towers are hundreds of resident /// megabytes; they reload lazily, and a fully indexed library @@ -844,7 +752,7 @@ impl Library { "rule": b.is_smart().then(|| b.rule_label()), "viewing": self.bucket_filter == Some(i), })).collect::>(), - "search": (!self.search.is_empty()).then_some(&self.search), + "search": (!self.search.text.is_empty()).then_some(&self.search.text), "search_results": self.search_results.as_ref().map(|r| r.len()), "search_bucket": self .search_scoped @@ -1246,15 +1154,7 @@ impl Library { .rev() .map(|(key, mut entries)| { entries.sort_by_key(|e| std::cmp::Reverse(self.taken_of(e))); - let title = match ( - key.get(..4), - key.get(5..7).and_then(|m| m.parse::().ok()), - ) { - (Some(year), Some(month)) if (1..=12).contains(&month) => { - format!("{} {year}", MONTHS[month - 1]) - } - _ => "Undated".to_string(), - }; + let title = super::gallery_chrome::month_title(&key); (title, String::new(), entries) }) .collect() @@ -2703,6 +2603,10 @@ impl Workspace { /// Re-walk the watched folders on a background thread. pub fn library_rescan(&mut self, cx: &mut Context) { + if !self.library.people_models_started { + self.library.people_models_started = true; + self.download_people_models(cx); + } if self.library.folders.is_empty() { self.library.set_sections(Vec::new()); return; @@ -2903,7 +2807,7 @@ impl Workspace { /// flips the key context to text entry so letters reach the box /// instead of the tool shortcuts. pub fn gallery_search_active(&self) -> bool { - self.library.open && self.library.search_active + self.library.open && self.library.search.active } /// Ask what to call a new bucket — and, optionally, its smart rule @@ -2916,6 +2820,7 @@ impl Workspace { query: String::new(), photos, editing: None, + cloud: false, }, cx, ); @@ -2938,6 +2843,7 @@ impl Workspace { query, photos: Vec::new(), editing: Some(index), + cloud: false, }, cx, ); @@ -3128,7 +3034,7 @@ impl Workspace { ws.library.smart_running = false; // A search scoped to this bucket was ranked over what // it held before the pass; rank it over what it holds now. - if viewed_refilled && !ws.library.search.trim().is_empty() { + if viewed_refilled && !ws.library.search.text.trim().is_empty() { ws.gallery_search_changed(cx); } cx.notify(); @@ -3144,141 +3050,13 @@ impl Workspace { ev: &gpui::KeyDownEvent, cx: &mut Context, ) -> bool { - if !self.library.search_active { - return false; - } - let primary = ev.keystroke.modifiers.platform || ev.keystroke.modifiers.control; - // Keep the caret on the rails whatever changed the text. - self.library.search_cursor = self.library.search_cursor.min(self.library.search.len()); - match ev.keystroke.key.as_str() { - "a" if primary => { - self.library.search_selected = !self.library.search.is_empty(); - self.library.search_cursor = self.library.search.len(); - cx.notify(); - } - "c" if primary && self.library.search_selected => { - cx.write_to_clipboard(gpui::ClipboardItem::new_string(self.library.search.clone())); - } - "x" if primary && self.library.search_selected => { - cx.write_to_clipboard(gpui::ClipboardItem::new_string(self.library.search.clone())); - self.library.search.clear(); - self.library.search_cursor = 0; - self.library.search_selected = false; - self.gallery_search_changed(cx); - } - "v" if primary => { - let Some(pasted) = cx.read_from_clipboard().and_then(|item| item.text()) else { - return true; - }; - // One line: a pasted paragraph flattens rather than - // breaking the box. - let pasted: String = pasted - .chars() - .map(|c| if c.is_control() { ' ' } else { c }) - .collect(); - if self.library.search_selected { - self.library.search.clear(); - self.library.search_cursor = 0; - self.library.search_selected = false; - } - let at = self.library.search_cursor; - self.library.search.insert_str(at, &pasted); - self.library.search_cursor = at + pasted.len(); - self.gallery_search_changed(cx); - } - "left" | "right" if primary => { - // ⌘←/⌘→: the ends of the line. - self.library.search_selected = false; - self.library.search_cursor = if ev.keystroke.key == "left" { - 0 - } else { - self.library.search.len() - }; - cx.notify(); - } - "left" => { - self.library.search_cursor = if self.library.search_selected { - 0 - } else { - crate::ui::caret_left(&self.library.search, self.library.search_cursor) - }; - self.library.search_selected = false; - cx.notify(); - } - "right" => { - self.library.search_cursor = if self.library.search_selected { - self.library.search.len() - } else { - crate::ui::caret_right(&self.library.search, self.library.search_cursor) - .min(self.library.search.len()) - }; - self.library.search_selected = false; - cx.notify(); - } - "home" | "up" => { - self.library.search_cursor = 0; - self.library.search_selected = false; - cx.notify(); - } - "end" | "down" => { - self.library.search_cursor = self.library.search.len(); - self.library.search_selected = false; - cx.notify(); - } - "backspace" => { - if self.library.search_selected { - self.library.search.clear(); - self.library.search_cursor = 0; - self.library.search_selected = false; - } else if self.library.search_cursor > 0 { - let from = - crate::ui::caret_left(&self.library.search, self.library.search_cursor); - self.library - .search - .replace_range(from..self.library.search_cursor, ""); - self.library.search_cursor = from; - } - self.gallery_search_changed(cx); - } - "delete" => { - if self.library.search_selected { - self.library.search.clear(); - self.library.search_cursor = 0; - self.library.search_selected = false; - } else if self.library.search_cursor < self.library.search.len() { - let to = - crate::ui::caret_right(&self.library.search, self.library.search_cursor); - self.library - .search - .replace_range(self.library.search_cursor..to, ""); - } - self.gallery_search_changed(cx); - } - "enter" => { - // The results are already live; Enter just puts the - // keyboard back on the shortcuts. - self.library.search_active = false; - self.library.search_selected = false; - cx.notify(); - } - _ => { - let Some(text) = ev.keystroke.key_char.as_deref() else { - return false; - }; - if text.chars().any(char::is_control) { - return false; - } - // Typing over a selection replaces it, as anywhere. - if self.library.search_selected { - self.library.search.clear(); - self.library.search_cursor = 0; - self.library.search_selected = false; - } - let at = self.library.search_cursor; - self.library.search.insert_str(at, text); - self.library.search_cursor = at + text.len(); - self.gallery_search_changed(cx); - } + use crate::ui::LineEditKey; + match self.library.search.key(ev, cx) { + LineEditKey::Ignored => return false, + LineEditKey::Changed => self.gallery_search_changed(cx), + // Enter: the results are already live; it just puts the + // keyboard back on the shortcuts. + LineEditKey::Moved | LineEditKey::Submitted => cx.notify(), } // A key landed in the box: show the caret solid from here. self.reset_caret_phase(); @@ -3294,11 +3072,11 @@ impl Workspace { ev: &gpui::KeyDownEvent, cx: &mut Context, ) -> bool { - if self.library.search_active { + if self.library.search.active { return false; } let columns = { - let width = f32::from(self.library.grid_bounds.size.width); + let width = f32::from(self.library.grid.bounds.size.width); let cell = self.library.thumb_px; // p_2 padding both sides, gap_2 between cells. (((width - 16.0 + 8.0) / (cell + 8.0)).floor() as isize).max(1) @@ -3353,7 +3131,7 @@ impl Workspace { } else { self.library.select_single(lead); } - self.library.reveal_selection = true; + self.library.grid.reveal = true; cx.notify(); true } @@ -3409,45 +3187,23 @@ impl Workspace { /// Runs per render off the bounds the previous paint recorded, so /// it converges a frame after the selection moves. pub(super) fn gallery_reveal_tick(&mut self, cx: &mut Context) { - if !self.library.reveal_selection { - return; - } - let (Some(cell), view) = (self.library.selected_bounds, self.library.grid_bounds) else { - return; - }; - if view.size.height <= px(0.0) { - return; - } - let top = f32::from(cell.origin.y); - let bottom = top + f32::from(cell.size.height); - let view_top = f32::from(view.origin.y); - let view_bottom = view_top + f32::from(view.size.height); - let mut offset = self.library.grid_scroll.offset(); - if bottom > view_bottom { - // Scrolling down means a more negative offset in gpui. - offset.y -= px(bottom - view_bottom + 8.0); - } else if top < view_top { - offset.y += px(view_top - top + 8.0); - } else { - self.library.reveal_selection = false; - return; + if self.library.grid.reveal_tick() { + cx.notify(); } - self.library.grid_scroll.set_offset(offset); - cx.notify(); } /// Leave the search: clear the box and show the folders again. /// Wired into the always-on Escape path. pub(super) fn gallery_search_clear(&mut self, cx: &mut Context) -> bool { if !self.library.open - || (!self.library.search_active && self.library.search_results.is_none()) + || (!self.library.search.active && self.library.search_results.is_none()) { return false; } - self.library.search.clear(); - self.library.search_cursor = 0; - self.library.search_active = false; - self.library.search_selected = false; + self.library.search.text.clear(); + self.library.search.cursor = 0; + self.library.search.active = false; + self.library.search.selected = false; self.library.search_results = None; self.library.search_place = None; self.library.search_people.clear(); @@ -3466,7 +3222,7 @@ impl Workspace { pub(super) fn gallery_search_changed(&mut self, cx: &mut Context) { self.library.search_seq += 1; let seq = self.library.search_seq; - let query = self.library.search.trim().to_string(); + let query = self.library.search.text.trim().to_string(); self.library.search_scoped = self.library.bucket_filter; if query.is_empty() { self.library.search_results = None; @@ -3594,8 +3350,8 @@ impl Workspace { cx: &mut Context, ) -> Vec<(PathBuf, f32)> { let query = query.trim().to_string(); - self.library.search = query.clone(); - self.library.search_cursor = query.len(); + self.library.search.text = query.clone(); + self.library.search.cursor = query.len(); self.library.search_seq += 1; self.library.search_scoped = self.library.bucket_filter; let scope = self.library.search_scope(); @@ -3674,7 +3430,7 @@ impl Workspace { /// buckets' refresh, so nothing that moves `bucket_filter` has to /// remember to. pub(super) fn gallery_search_rescope(&mut self, cx: &mut Context) { - if self.library.search.trim().is_empty() + if self.library.search.text.trim().is_empty() || self.library.search_scoped == self.library.bucket_filter { return; @@ -4174,9 +3930,24 @@ impl Workspace { /// Open the map-filter dialog, seeded with the active filter so /// editing starts from what is on. pub fn open_map_filter(&mut self, cx: &mut Context) { + if self.cloud.show { + let bounds = self.cloud.query.filters.bounds.as_ref().map(|b| GeoBounds { + south: b.south, + north: b.north, + west: b.west, + east: b.east, + }); + self.library.map.selection = bounds; + self.library.map.selection_name = None; + if let Some(bounds) = bounds { + self.library.map.center = bounds.center(); + } + self.open_modal(Modal::MapFilter, cx); + return; + } + self.library.map.selection = self.library.map_filter; + self.library.map.selection_name = self.library.map_filter_name.clone(); if let Some(bounds) = self.library.map_filter { - self.library.map.selection = Some(bounds); - self.library.map.selection_name = self.library.map_filter_name.clone(); self.library.map.center = bounds.center(); } self.open_modal(Modal::MapFilter, cx); @@ -4185,6 +3956,20 @@ impl Workspace { /// Make the drawn boundary the gallery's filter (or clear it, when /// nothing is drawn), and remember it. pub fn apply_map_filter(&mut self, cx: &mut Context) { + if self.cloud.show { + self.cloud.query.filters.bounds = + self.library.map.selection.map(|b| schist_cloud::Bounds { + south: b.south, + north: b.north, + west: b.west, + east: b.east, + }); + self.cloud.query.offset = 0; + self.cloud_watch_assets(true); + self.close_modal(cx); + cx.notify(); + return; + } self.library.map_filter = self.library.map.selection; self.library.map_filter_name = self .library @@ -4197,6 +3982,13 @@ impl Workspace { /// Turn the map filter off. The boundary stays drawn on the map, so /// turning it back on is one Apply away. pub fn clear_map_filter(&mut self, cx: &mut Context) { + if self.cloud.show { + self.cloud.query.filters.bounds = None; + self.cloud.query.offset = 0; + self.cloud_watch_assets(true); + cx.notify(); + return; + } self.library.map_filter = None; self.library.map_filter_name = None; cx.notify(); @@ -4206,6 +3998,11 @@ impl Workspace { pub fn set_gallery_group(&mut self, group: GroupBy, cx: &mut Context) { self.library.group_by = group; self.library.save(); + if self.cloud.show { + self.cloud.query.offset = 0; + self.cloud.query.sort = self.cloud_sort(); + self.cloud_watch_assets(true); + } cx.notify(); } diff --git a/crates/app/src/workspace/library_ops.rs b/crates/app/src/workspace/library_ops.rs index 16ab0015..b5994a6c 100644 --- a/crates/app/src/workspace/library_ops.rs +++ b/crates/app/src/workspace/library_ops.rs @@ -865,7 +865,7 @@ fn deflate(bytes: &[u8]) -> Vec { encoder.finish().unwrap_or_default() } -struct ZipWriter { +pub(super) struct ZipWriter { file: std::io::BufWriter, /// Where the archive is being built, and where it lands on /// `finish` — a half-written ZIP never takes the real name. @@ -878,7 +878,7 @@ struct ZipWriter { } impl ZipWriter { - fn create(out: &Path) -> anyhow::Result { + pub(super) fn create(out: &Path) -> anyhow::Result { let tmp = out.with_extension("schist-tmp"); Ok(ZipWriter { file: std::io::BufWriter::new(std::fs::File::create(&tmp)?), @@ -891,7 +891,7 @@ impl ZipWriter { }) } - fn add(&mut self, name: &str, bytes: &[u8]) -> anyhow::Result<()> { + pub(super) fn add(&mut self, name: &str, bytes: &[u8]) -> anyhow::Result<()> { use std::io::Write as _; if bytes.len() as u64 > u32::MAX as u64 { anyhow::bail!("too large for a zip without zip64"); @@ -946,7 +946,7 @@ impl ZipWriter { Ok(()) } - fn finish(mut self) -> anyhow::Result<()> { + pub(super) fn finish(mut self) -> anyhow::Result<()> { use std::io::Write as _; if self.entries == 0 { let _ = std::fs::remove_file(&self.tmp); diff --git a/crates/app/src/workspace/library_people.rs b/crates/app/src/workspace/library_people.rs index 4b73d677..00a6d554 100644 --- a/crates/app/src/workspace/library_people.rs +++ b/crates/app/src/workspace/library_people.rs @@ -15,7 +15,7 @@ use schist_gallery::*; use std::path::Path; /// The two models the People album runs on — the detector that finds -/// faces and the recogniser that tells them apart — offered as a pair. +/// faces and the recogniser that tells them apart — installed automatically as a pair. pub(crate) const PEOPLE_MODELS: [&str; 2] = ["face", "face-embed"]; impl Workspace { @@ -437,7 +437,7 @@ impl Workspace { /// Escape in the gallery, innermost first: a search, then a face /// being named, then the viewer itself. Returns whether anything /// was there to leave. - pub fn gallery_escape(&mut self, cx: &mut Context) -> bool { + pub fn local_gallery_escape(&mut self, cx: &mut Context) -> bool { if self.gallery_search_clear(cx) { return true; } @@ -466,9 +466,14 @@ impl Workspace { cx.notify(); } - /// Offer the two People models, licences first. - pub fn open_people_models(&mut self, cx: &mut Context) { - self.open_modal(Modal::PeopleModels, cx); + /// Install missing People models without a setup dialog. The downloader + /// deduplicates in-flight requests; the sidebar offers retry after failure. + pub fn download_people_models(&mut self, cx: &mut Context) { + for id in PEOPLE_MODELS { + if !schist_neural::installed(id) { + self.download_model(id, cx); + } + } } /// The rename dialog, its field already taking typing. diff --git a/crates/app/src/workspace/library_people_view.rs b/crates/app/src/workspace/library_people_view.rs index 3efce630..18b8f908 100644 --- a/crates/app/src/workspace/library_people_view.rs +++ b/crates/app/src/workspace/library_people_view.rs @@ -1,15 +1,15 @@ //! The People album on screen: the sidebar's rows, the viewer with its -//! face boxes and people panel, and the two dialogs (the models to -//! install, a person's rename). Drawn on the gallery's palette, like +//! face boxes and people panel, and the person rename dialog. Drawn on the gallery's palette, like //! the rest of the room. +use super::gallery_chrome::{gallery_button, pal}; use super::library::{FaceView, GalleryContext, PersonFilter, AVATAR_PX}; use super::library_people::PEOPLE_MODELS; -use super::library_view::{bucket_field, gallery_button, pal}; +use super::library_view::bucket_field; use super::*; use gpui::{img, StatefulInteractiveElement as _}; use schist_gallery::FaceRect; -use schist_ui::{Button, ButtonColors, Link}; +use schist_ui::{Button, ButtonColors}; use std::path::Path; /// The colour of a face box by its state: named, picked, guessed, or @@ -123,6 +123,7 @@ pub(super) fn people_rows( MouseButton::Right, cx.listener(move |ws, ev: &MouseDownEvent, _w, cx| { ws.library.context = Some((ev.position, GalleryContext::Person(index))); + ws.cloud.context = None; cx.notify(); }), ) @@ -204,16 +205,16 @@ pub(super) fn people_rows( .text_color(gpui::rgb(pal().header)) .cursor_pointer() .hover(|s| s.bg(gpui::rgb(pal().sidebar_selected))) - .on_click(cx.listener(|ws, _e, _w, cx| ws.open_people_models(cx))) + .on_click(cx.listener(|ws, _e, _w, cx| ws.download_people_models(cx))) .child(label) .into_any_element() }; if people_models_downloading(ws) { rows.push(people_download_progress(ws).into_any_element()); } else if !detector { - rows.push(link("+ Find faces\u{2026}", cx)); + rows.push(link("Retry face model download", cx)); } else if !recogniser { - rows.push(link("+ Recognise faces\u{2026}", cx)); + rows.push(link("Retry recognition model download", cx)); } else if !any_people && unnamed == 0 { let (looked, total) = ws.library.faces_progress(); rows.push( @@ -631,9 +632,9 @@ fn people_panel( ), ) .child(gallery_button( - "Find faces\u{2026}", + "Retry face model download", true, - |ws, _w, cx| ws.open_people_models(cx), + |ws, _w, cx| ws.download_people_models(cx), cx, )); } else if !looked { @@ -979,97 +980,6 @@ fn name_field(ws: &Workspace, cx: &mut Context) -> impl IntoElement { }) } -fn model_link(id: &'static str, label: &'static str, url: &'static str) -> Link { - Link::new(id, label).url(url) -} - -/// The licences behind the People album, and the button that accepts -/// them. One dialog for both models: whichever is missing is fetched. -pub(crate) fn people_models_dialog(cx: &mut Context) -> impl IntoElement { - let specs: Vec<&'static schist_neural::ModelSpec> = PEOPLE_MODELS - .iter() - .filter(|id| !schist_neural::installed(id)) - .filter_map(|id| schist_neural::spec(id)) - .collect(); - let total: usize = specs.iter().map(|s| s.bytes).sum(); - let mut body = div().flex().flex_col().gap_2().w(px(460.0)).child( - div() - .text_size(px(12.0)) - .text_color(gpui::rgb(crate::ui::palette().text)) - .child( - "Finding the people in your photos needs two small models, \ - downloaded once and kept on this machine. They run locally: \ - no photo ever leaves it. The detector finds faces; the \ - recogniser tells them apart, so once you have named someone \ - a few times it can suggest them elsewhere.", - ), - ); - for spec in &specs { - body = body.child(div().text_size(px(12.0)).child(SharedString::from(format!( - "{} \u{b7} {:.1} MB \u{b7} {}", - spec.name, - spec.bytes as f64 / (1 << 20) as f64, - spec.license - )))); - } - body = body.child( - div() - .flex() - .flex_row() - .items_center() - .gap_1() - .text_size(px(11.0)) - .text_color(gpui::rgb(crate::ui::palette().text_dim)) - .child(model_link( - "ultraface-source", - "UltraFace (ONNX Model Zoo)", - "https://github.com/onnx/models/tree/main/validated/vision/body_analysis/ultraface", - )) - .child("\u{b7}") - .child(model_link( - "sface-source", - "SFace (OpenCV Zoo)", - "https://github.com/opencv/opencv_zoo/tree/main/models/face_recognition_sface", - )), - ); - body = body.child( - div() - .pt_1() - .text_size(px(11.0)) - .text_color(gpui::rgb(crate::ui::palette().text_dim)) - .child(SharedString::from(format!( - "Downloading installs {} ({:.1} MB) and accepts the licences. They can be \ - removed again under Gallery \u{25b8} Manage Models\u{2026}", - if specs.len() == 1 { "it" } else { "both" }, - total as f64 / (1 << 20) as f64 - ))), - ); - let actions = div() - .flex() - .flex_row() - .gap_2() - .child(crate::ui::button( - "Cancel", - false, - |ws, _w, cx| ws.close_modal(cx), - cx, - )) - .child(crate::ui::button( - "Agree and Download", - true, - |ws, _w, cx| { - for id in PEOPLE_MODELS { - if !schist_neural::installed(id) { - ws.download_model(id, cx); - } - } - ws.close_modal(cx); - }, - cx, - )); - crate::ui::modal_frame("People", 500.0, body, actions) -} - /// Rename a person. Renaming to a name somebody else has merges them. pub(crate) fn person_name_dialog( ws: &mut Workspace, diff --git a/crates/app/src/workspace/library_view.rs b/crates/app/src/workspace/library_view.rs index e4733a69..091b659b 100644 --- a/crates/app/src/workspace/library_view.rs +++ b/crates/app/src/workspace/library_view.rs @@ -3,12 +3,16 @@ //! folder headers, and a tray along the bottom with the green action //! button and the thumbnail-size slider. //! -//! It keeps its own palette rather than `ui::palette()` — a photo grid -//! wants quieter, flatter chrome than a panel set — but it follows the -//! theme choice: the light theme gets Picasa's warm white lightbox, the -//! dark theme a Lightroom-grey version of the same room, so opening the -//! gallery from a dark editor is not a flashbang. +//! The chrome — palette, strip, tray, sidebar rows, grid frame, cells — +//! lives in `gallery_chrome`, shared with the Schist Cloud gallery, +//! which is the same room showing a remote library. This file is the +//! local half: watched folders, the photo search, the local buckets. +use super::gallery_chrome::{ + self as chrome, cell_frame, empty_note, gallery_button, grid_column, grid_frame, group_chips, + lead_probe, pal, search_field, section_header, sidebar_caption, sidebar_column, DragGhost, + GroupBy, TrayInfo, +}; use super::*; use gpui::{ img, prelude::FluentBuilder as _, AppContext as _, StatefulInteractiveElement as _, @@ -16,82 +20,10 @@ use gpui::{ }; use schist_ui::{Button, ButtonColors, Link, ListItem}; -/// The gallery's chrome colours for one theme. -pub(super) struct GalleryPalette { - /// Behind the thumbnails. - pub(super) grid_bg: u32, - /// The top strip and sidebar. - pub(super) chrome_bg: u32, - pub(super) chrome_edge: u32, - pub(super) tray_bg: u32, - pub(super) sidebar_selected: u32, - /// Folder headers and the add-folder link — Picasa's blue. - pub(super) header: u32, - pub(super) text: u32, - pub(super) text_dim: u32, - pub(super) cell_edge: u32, - /// Cell border under the pointer. - pub(super) cell_hover: u32, - pub(super) select_border: u32, - pub(super) select_fill: u32, - pub(super) button_bg: u32, - pub(super) button_hover: u32, - /// The green action buttons and the "edited" badge. - pub(super) green: u32, - pub(super) green_hover: u32, -} - -/// Picasa: white grid, warm grey chrome. -const GALLERY_LIGHT: GalleryPalette = GalleryPalette { - grid_bg: 0xFFFFFF, - chrome_bg: 0xEDEDE6, - chrome_edge: 0xC9C9C0, - tray_bg: 0xE3E3DC, - sidebar_selected: 0xCFE0F2, - header: 0x2A5DB0, - text: 0x2B2B2B, - text_dim: 0x7A7A72, - cell_edge: 0xDDDDDD, - cell_hover: 0xB9CBE0, - select_border: 0x4A90D9, - select_fill: 0xE8F0FB, - button_bg: 0xF7F7F2, - button_hover: 0xFFFFFF, - green: 0x5C9E31, - green_hover: 0x6DB33F, -}; - -/// The same room with the lights down — Lightroom's greys. -const GALLERY_DARK: GalleryPalette = GalleryPalette { - grid_bg: 0x232323, - chrome_bg: 0x2B2B2B, - chrome_edge: 0x1C1C1C, - tray_bg: 0x282828, - sidebar_selected: 0x3A4A5C, - header: 0x7FB0E8, - text: 0xD8D8D8, - text_dim: 0x8F8F8A, - cell_edge: 0x3A3A3A, - cell_hover: 0x55708C, - select_border: 0x4A90D9, - select_fill: 0x2C3A4A, - button_bg: 0x383838, - button_hover: 0x444444, - green: 0x5C9E31, - green_hover: 0x6DB33F, -}; - -pub(super) fn pal() -> &'static GalleryPalette { - if crate::ui::is_light() { - &GALLERY_LIGHT - } else { - &GALLERY_DARK - } -} - impl Workspace { pub(super) fn render_gallery(&mut self, cx: &mut Context) -> impl IntoElement { - let body = if self.library.folders.is_empty() { + let cloud = self.cloud.show; + let body = if self.library.folders.is_empty() && self.cloud.account.is_none() { gallery_empty_state(cx).into_any_element() } else { div() @@ -102,6 +34,8 @@ impl Workspace { .child(sidebar(self, cx)) .child(if self.library.map_view { world_map(self, cx).into_any_element() + } else if cloud { + super::cloud_view::grid(self, cx) } else if self.library.viewer.is_some() { super::library_people_view::viewer(self, cx) } else { @@ -113,7 +47,10 @@ impl Workspace { .children(crate::panels::ai_sidebar(self, cx)) .into_any_element() }; - let context_menu = gallery_context_menu(self, cx); + // Both rooms list both kinds of row, so either menu can be up + // in either room; opening one closes the other. + let context_menu = + super::cloud_view::context_menu(self, cx).or_else(|| gallery_context_menu(self, cx)); let root = div() .flex() .flex_col() @@ -144,32 +81,44 @@ impl Workspace { cx.stop_propagation(); return; } - if ws.gallery_viewer_key(ev, cx) - || ws.gallery_search_key(ev, cx) - || ws.gallery_nav_key(ev, cx) - { + if ws.gallery_key(ev, cx) { cx.stop_propagation(); } })) - .child(top_strip(self, cx)) + .child(chrome::top_strip(self, cx)) + .children( + (self.cloud.account.is_none() && self.cloud.message != "Not signed in").then( + || { + div() + .px_3() + .py_2() + .text_size(px(12.0)) + .child(self.cloud.message.clone()) + }, + ), + ) .child(body) - .child(tray(self, cx)) + .child(chrome::tray(self, cx)) .children(context_menu) .child(drag_out_listener(cx)); - // Each cell's paint-time probe is what queues its thumbnail; - // mark the frame so those probes stamp as "current" (the age - // eviction refuses), and make sure a loader is running for - // whatever the last frame asked for — and if decodes have been - // failing for want of HEIC support, offer it. - self.library.begin_thumb_frame(); - self.kick_thumb_loader(cx); - // Smart buckets re-score whenever the index moved, so they - // fill themselves as photos are indexed and imported — and a - // search follows the bucket on show. - self.refresh_smart_buckets(cx); - self.gallery_search_rescope(cx); - self.maybe_offer_heif(cx); - self.gallery_reveal_tick(cx); + if cloud { + self.cloud_reveal_tick(cx); + } else { + // Each cell's paint-time probe is what queues its thumbnail; + // mark the frame so those probes stamp as "current" (the age + // eviction refuses), and make sure a loader is running for + // whatever the last frame asked for — and if decodes have been + // failing for want of HEIC support, offer it. + self.library.begin_thumb_frame(); + self.kick_thumb_loader(cx); + // Smart buckets re-score whenever the index moved, so they + // fill themselves as photos are indexed and imported — and a + // search follows the bucket on show. + self.refresh_smart_buckets(cx); + self.gallery_search_rescope(cx); + self.maybe_offer_heif(cx); + self.gallery_reveal_tick(cx); + } root } } @@ -224,137 +173,22 @@ fn drag_out_listener(cx: &mut Context) -> impl IntoElement { .size_0() } -pub(super) fn gallery_button( - label: &'static str, - green: bool, - on_click: impl Fn(&mut Workspace, &mut Window, &mut Context) + 'static, +/// Local filter chips and search inside the shared toolbar. +pub(super) fn local_strip_search( + strip: gpui::Div, + ws: &mut Workspace, cx: &mut Context, -) -> impl IntoElement { - // The kit's button on the gallery's own palette: it fires on - // release, like every other button in the app (issue #119). - let colors = if green { - ButtonColors { - bg: Some(pal().green), - hover: pal().green_hover, - text: 0xFFFFFF, - border: Some(pal().green), - } - } else { - ButtonColors { - bg: Some(pal().button_bg), - hover: pal().button_hover, - text: pal().text, - border: Some(pal().chrome_edge), - } - }; - Button::new(label, label) - .colors(colors) - .rounded_md() - .on_click(cx.listener(move |ws, _e, window, cx| on_click(ws, window, cx))) -} - -/// The strip under the menu bar: import and folder buttons on the left, -/// as Picasa keeps its Import button, and the way back to the editor on -/// the right. -fn top_strip(ws: &mut Workspace, cx: &mut Context) -> impl IntoElement { - let has_doc = ws.doc.is_some(); - let importing = ws.library.importing; - div() - .flex() - .flex_row() - .items_center() - .gap_2() - .h(px(38.0)) - .flex_none() - .px_2() - .bg(gpui::rgb(pal().chrome_bg)) - .border_b_1() - .border_color(gpui::rgb(pal().chrome_edge)) - .child(gallery_button( - if importing { - "Importing…" - } else { - "Import…" - }, - true, - |ws, _w, cx| ws.gallery_import_camera(cx), - cx, - )) - .child(gallery_button( - "Add Folder…", - false, - |ws, window, cx| ws.gallery_add_folder(window, cx), - cx, - )) - .child(gallery_button( - "Refresh", - false, - |ws, _w, cx| ws.library_rescan(cx), - cx, - )) - .child(div().flex_grow()) +) -> gpui::Div { + strip .children(ws.library.map_filter_label().map(|label| { - // While the map filter is on it wears the least - // ignorable thing in the strip — a filter you forgot is a - // gallery that looks mysteriously empty. - Button::bare("map-filter-chip") - .colors(ButtonColors { - bg: Some(pal().select_border), - hover: pal().select_border, - text: 0xFFFFFF, - border: None, - }) - .px_2() - .rounded_md() - .on_click(cx.listener(|ws, _e, _w, cx| ws.open_map_filter(cx))) - .child(format!("Map filter: {label}")) - .child( - // The × swallows its press so it does not also - // open the filter it is clearing. - Button::new("map-filter-clear", "\u{2715}") - .colors(ButtonColors { - bg: None, - hover: 0xFFFFFF30, - text: 0xFFFFFF, - border: None, - }) - .h_auto() - .px_1() - .consume_press() - .on_click(cx.listener(|ws, _e, _w, cx| ws.clear_map_filter(cx))), - ) - })) - .child(search_slot(ws, cx)) - .child(div().flex_grow()) - .child(gallery_button( - "Settings…", - false, - |ws, _w, cx| { - ws.snapshot_preferences(); - ws.open_modal(Modal::Preferences, cx); - }, - cx, - )) - .child(gallery_button( - "Open…", - false, - crate::keymap::open_file_dialog, - cx, - )) - .child(gallery_button( - "New File…", - false, - |ws, _w, cx| ws.open_new_file_picker(cx), - cx, - )) - .children(has_doc.then(|| { - gallery_button( - "Back to Editing", - false, - |ws, _w, cx| ws.toggle_gallery(cx), + chrome::filter_chip( + format!("Map filter: {label}"), + |ws, cx| ws.open_map_filter(cx), + |ws, cx| ws.clear_map_filter(cx), cx, ) })) + .child(search_slot(ws, cx)) } /// The search box: type a description, photos rank by it. Takes the @@ -458,8 +292,6 @@ fn search_download_progress(ws: &Workspace) -> impl IntoElement { /// The box itself, which only exists once the models behind it do — /// `search_slot` is what decides that. fn search_box(ws: &mut Workspace, cx: &mut Context) -> impl IntoElement { - let active = ws.library.search_active; - let text = ws.library.search.clone(); let (indexed, total) = ws.library.index_progress(); let placeholder: SharedString = if !schist_neural::embed::ready() { "Search people and places\u{2026}".into() @@ -468,100 +300,23 @@ fn search_box(ws: &mut Workspace, cx: &mut Context) -> impl IntoEleme } else { "Search photos\u{2026}".into() }; - let cursor = ws.library.search_cursor.min(text.len()); let caret_on = ws.caret_on(); - div() - .flex() - .flex_row() - .items_center() - .gap_1() - .w(px(260.0)) - .h(px(24.0)) - .px_2() - .rounded_md() - .bg(gpui::rgb(pal().grid_bg)) - .border_1() - .border_color(gpui::rgb(if active { - pal().select_border - } else { - pal().chrome_edge - })) - .text_size(px(12.0)) - .text_color(gpui::rgb(if text.is_empty() { - pal().text_dim - } else { - pal().text - })) - .cursor(gpui::CursorStyle::IBeam) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |ws, _e: &MouseDownEvent, _w, cx| { - ws.library.search_active = true; - // A click lands a caret, not a selection. - ws.library.search_selected = false; - ws.library.search_cursor = ws.library.search.len(); - ws.reset_caret_phase(); - // Focusing the box is the signal to start loading the - // towers, so the first query answers quickly without - // every gallery open paying their ~300 MB up front. - ws.warm_search_engine(cx); - cx.notify(); - }), - ) - .child(div().flex_grow().truncate().child( - // ⌘A's selection, drawn the way every field draws one; a - // focused box otherwise shows a blinking caret the arrows - // move, with the placeholder ghosted while it is empty. - if ws.library.search_selected && !text.is_empty() { - div() - .rounded_sm() - .px(px(1.0)) - .bg(gpui::rgb(pal().select_border)) - .text_color(gpui::rgb(0xFFFFFF)) - .child(SharedString::from(text.clone())) - .into_any_element() - } else if active { - div() - .flex() - .flex_row() - .items_center() - .child(crate::ui::caret_run( - text[..cursor].to_string(), - text[cursor..].to_string(), - caret_on, - pal().text, - )) - .children(text.is_empty().then(|| { - div() - .text_color(gpui::rgb(pal().text_dim)) - .child(placeholder.clone()) - })) - .into_any_element() - } else if text.is_empty() { - div().child(placeholder.clone()).into_any_element() - } else { - div() - .child(SharedString::from(text.clone())) - .into_any_element() - }, - )) - .children((!text.is_empty()).then(|| { - // Swallows its press, so clearing does not also focus the - // box it just emptied. - Button::new("search-clear", "\u{2715}") - .colors(ButtonColors { - bg: None, - hover: pal().button_hover, - text: pal().text_dim, - border: None, - }) - .h_auto() - .px_1() - .consume_press() - .on_click(cx.listener(|ws, _e, _w, cx| { - ws.gallery_search_clear(cx); - })) - })) + search_field( + &ws.library.search, + placeholder, + caret_on, + |ws, cx| { + ws.library.search.focus(); + // Focusing the box is the signal to start loading the + // towers, so the first query answers quickly without + // every gallery open paying their ~300 MB up front. + ws.warm_search_engine(cx); + }, + |ws, cx| { + ws.gallery_search_clear(cx); + }, + cx, + ) } /// Nothing watched yet, which for most people is the first launch: @@ -591,6 +346,12 @@ fn gallery_empty_state(cx: &mut Context) -> impl IntoElement { .child("Welcome to Schist"), ) .child(div().h(px(12.0))) + .child(crate::ui::button( + "Sign into Schist Cloud…", + false, + |ws, _, cx| ws.cloud_sign_in(cx), + cx, + )) .child(caption( "Watch folders of photos, or import from a camera. Files stay \ where they are; edits are versioned beside them:", @@ -660,8 +421,11 @@ fn gallery_empty_state(cx: &mut Context) -> impl IntoElement { .child(column) } -/// The folder list: Picasa's left column, minus the years. +/// The folder list: Picasa's left column, minus the years. The local +/// folders and buckets first, then the Schist Cloud library beneath, +/// laid out the same way. fn sidebar(ws: &mut Workspace, cx: &mut Context) -> impl IntoElement { + let cloud = ws.cloud.show; let filter = ws.library.folder_filter.clone(); let folders: Vec<(PathBuf, usize)> = ws .library @@ -680,170 +444,113 @@ fn sidebar(ws: &mut Workspace, cx: &mut Context) -> impl IntoElement .collect(); let total: usize = folders.iter().map(|(_, n)| n).sum(); let mut rows: Vec = Vec::new(); - rows.push(sidebar_row("All Photos", total, filter.is_none(), None, cx).into_any_element()); + rows.push( + sidebar_row( + "All Photos", + total, + !cloud && ws.library.bucket_filter.is_none() && filter.is_none(), + None, + cx, + ) + .into_any_element(), + ); for (root, count) in folders { let label = root .file_name() .map(|n| n.to_string_lossy().into_owned()) .unwrap_or_else(|| root.display().to_string()); - let selected = filter.as_deref() == Some(root.as_path()); + let selected = !cloud + && ws.library.bucket_filter.is_none() + && filter.as_deref() == Some(root.as_path()); rows.push(sidebar_row(label, count, selected, Some(root), cx).into_any_element()); } - div() - .id("gallery-sidebar") - .flex() - .flex_col() - .w(px(210.0)) - .flex_none() - .overflow_y_scroll() - .bg(gpui::rgb(pal().chrome_bg)) - .border_r_1() - .border_color(gpui::rgb(pal().chrome_edge)) - .child( - div() - .px_2() - .pt_2() - .pb_1() - .text_size(px(11.0)) - .text_color(gpui::rgb(pal().text_dim)) - .child("VIEW"), - ) + sidebar_column("gallery-sidebar") + .child(sidebar_caption("VIEW")) .children( - [(false, "Photos"), (true, "World Map")] + Some(()) + .map(|()| { + [(false, "Photos"), (true, "World Map")] + .into_iter() + .map(|(map, label)| { + ListItem::new(label) + .h(px(26.0)) + .px_2() + .bg(gpui::rgb(if ws.library.map_view == map { + pal().sidebar_selected + } else { + pal().chrome_bg + })) + .on_click(cx.listener(move |ws, _, _, cx| { + ws.library.map_view = map; + cx.notify(); + })) + .child(label) + }) + }) .into_iter() - .map(|(map, label)| { - let active = ws.library.map_view == map; - div() - .px_2() - .h(px(26.0)) - .flex() - .items_center() - .text_size(px(12.0)) - .cursor_pointer() - .bg(gpui::rgb(if active { - pal().sidebar_selected - } else { - pal().chrome_bg - })) - .hover(|s| s.bg(gpui::rgb(pal().sidebar_selected))) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |ws, _, _, cx| { - ws.library.map_view = map; - cx.notify(); - }), - ) - .child(label) - }), + .flatten(), ) - .child( + .child(group_chips(ws.gallery_group_by(), &GroupBy::ALL, cx)) + .child({ + let active = if cloud { + ws.cloud.query.filters.bounds.is_some() + } else { + ws.library.map_filter.is_some() + }; div() + .mx_2() + .mb_1() .px_2() - .pt_2() - .pb_1() + .h(px(22.0)) + .flex() + .items_center() + .justify_between() + .rounded_md() .text_size(px(11.0)) - .text_color(gpui::rgb(pal().text_dim)) - .child("GROUP BY"), - ) - .child({ - let current = ws.library.group_by; - let mut row = div().flex().flex_row().gap_1().px_2().pb_2(); - for (i, group) in super::library::GroupBy::ALL.into_iter().enumerate() { - let active = group == current; - row = row.child( - Button::new(("group-by", i), group.label()) - .colors(ButtonColors { - bg: Some(if active { - pal().sidebar_selected - } else { - pal().button_bg - }), - hover: if active { - pal().sidebar_selected - } else { - pal().button_hover - }, - text: pal().text, - border: None, - }) - .h(px(20.0)) - .px_2() - .rounded_md() - .text_size(px(11.0)) - .on_click(cx.listener(move |ws, _e, _w, cx| { - ws.set_gallery_group(group, cx); - })), - ); - } - row - }) - .child({ - let active = ws.library.map_filter.is_some(); - Button::new( - "map-filter", - if active { - "Map filter on" - } else { - "Map filter…" - }, - ) - .colors(ButtonColors { - bg: Some(if active { + .cursor_pointer() + .bg(gpui::rgb(if active { pal().select_border } else { pal().button_bg - }), - hover: if active { - pal().select_border + })) + .text_color(gpui::rgb(if active { 0xFFFFFF } else { pal().text })) + .hover(move |s| { + if active { + s + } else { + s.bg(gpui::rgb(pal().button_hover)) + } + }) + .on_mouse_down( + MouseButton::Left, + cx.listener(|ws, _e: &MouseDownEvent, _w, cx| ws.open_map_filter(cx)), + ) + .child(if active { + "Map filter on" } else { - pal().button_hover - }, - text: if active { 0xFFFFFF } else { pal().text }, - border: None, - }) - .mx_2() - .mb_1() - .px_2() - .h(px(22.0)) - .justify_between() - .rounded_md() - .text_size(px(11.0)) - .on_click(cx.listener(|ws, _e, _w, cx| ws.open_map_filter(cx))) - .children(active.then(|| div().child("\u{25cf}"))) + "Map filter…" + }) + .children(active.then(|| div().child("\u{25cf}"))) }) - .child( - div() - .px_2() - .pt_1() - .pb_1() - .text_size(px(11.0)) - .text_color(gpui::rgb(pal().text_dim)) - .child("FOLDERS"), - ) + .child(sidebar_caption("FOLDERS")) .children(rows) - .child( - div() - .id("add-folder") - .px_2() - .h(px(24.0)) - .flex() - .items_center() - .text_size(px(12.0)) - .text_color(gpui::rgb(pal().header)) - .cursor_pointer() - .hover(|s| s.bg(gpui::rgb(pal().sidebar_selected))) - .on_click(cx.listener(|ws, _e, window, cx| ws.gallery_add_folder(window, cx))) - .child("+ Add folder…"), - ) - .child( - div() - .px_2() - .pt_2() - .pb_1() - .text_size(px(11.0)) - .text_color(gpui::rgb(pal().text_dim)) - .child("BUCKETS"), - ) + .children(super::cloud_view::folder_rows(ws, cx)) + .child(chrome::sidebar_menu_link( + "+ Add folder…", + |ws, at, window, cx| { + // With a cloud signed in there are two kinds of folder + // to add; without one there is only the local kind. + if ws.cloud.account.is_some() { + ws.library.context = Some((at, super::library::GalleryContext::AddFolder)); + ws.cloud.context = None; + cx.notify(); + } else { + ws.gallery_add_folder(window, cx); + } + }, + cx, + )) + .child(sidebar_caption("BUCKETS")) .children({ let buckets: Vec<(usize, String, usize, bool)> = ws .library @@ -852,7 +559,11 @@ fn sidebar(ws: &mut Workspace, cx: &mut Context) -> impl IntoElement .enumerate() .map(|(i, b)| (i, b.name.clone(), b.contents().len(), b.is_smart())) .collect(); - let viewing = ws.library.bucket_filter; + let viewing = if cloud { + None + } else { + ws.library.bucket_filter + }; let mut rows: Vec = Vec::new(); for (i, name, count, smart) in buckets { rows.push( @@ -861,26 +572,25 @@ fn sidebar(ws: &mut Workspace, cx: &mut Context) -> impl IntoElement } rows }) - .child( - div() - .id("new-bucket") - .px_2() - .h(px(24.0)) - .flex() - .items_center() - .text_size(px(12.0)) - .text_color(gpui::rgb(pal().header)) - .cursor_pointer() - .hover(|s| s.bg(gpui::rgb(pal().sidebar_selected))) - .on_click(cx.listener(|ws, _e, _w, cx| { + .children(super::cloud_view::bucket_rows(ws, cx)) + .child(chrome::sidebar_menu_link( + "+ New bucket", + |ws, at, _window, cx| { + if ws.cloud.account.is_some() { + ws.library.context = Some((at, super::library::GalleryContext::NewBucket)); + ws.cloud.context = None; + cx.notify(); + } else { // Born holding the selection, so "new bucket // from these" is the dialog's Create away. let selected = ws.library.selected.clone(); ws.gallery_new_bucket(selected, cx); - })) - .child("+ New bucket"), - ) + } + }, + cx, + )) .children(super::library_people_view::people_rows(ws, cx)) + .children(super::cloud_people::rows(ws, false, cx)) } /// One bucket in the sidebar: a drop target, a view of its contents on @@ -918,6 +628,7 @@ fn bucket_row( .on_mouse_down( MouseButton::Left, cx.listener(move |ws, _e: &MouseDownEvent, _w, cx| { + ws.cloud.show = false; ws.library.bucket_filter = if ws.library.bucket_filter == Some(index) { None } else { @@ -931,6 +642,7 @@ fn bucket_row( MouseButton::Right, cx.listener(move |ws, ev: &MouseDownEvent, _w, cx| { ws.library.context = Some((ev.position, GalleryContext::Bucket(index))); + ws.cloud.context = None; cx.notify(); }), ) @@ -961,6 +673,12 @@ fn sidebar_row( ) -> impl IntoElement { let filter = root.clone(); let mut row = div() + .id(SharedString::from(format!( + "local-folder:{}", + root.as_ref() + .map(|p| p.to_string_lossy()) + .unwrap_or_default() + ))) .flex() .flex_row() .items_center() @@ -978,11 +696,27 @@ fn sidebar_row( .on_mouse_down( MouseButton::Left, cx.listener(move |ws, _e: &MouseDownEvent, _w, cx| { + ws.cloud.show = false; ws.library.folder_filter = filter.clone(); ws.library.bucket_filter = None; cx.notify(); }), ) + .children(root.clone().map(|menu_root| { + // The row's menu lives on a transparent overlay child so the + // left click above keeps its own handler. + div().absolute().size_full().on_mouse_down( + MouseButton::Right, + cx.listener(move |ws, ev: &MouseDownEvent, _w, cx| { + ws.library.context = Some(( + ev.position, + super::library::GalleryContext::LocalFolder(menu_root.clone()), + )); + ws.cloud.context = None; + cx.notify(); + }), + ) + })) .child(div().flex_grow().truncate().child(label.into())) .child( div() @@ -993,7 +727,22 @@ fn sidebar_row( if let Some(drop_root) = root.clone() { // Dragged photos land here as a move — files, sidecars, // versions and all. + let drag_path = drop_root.clone(); row = row + .on_drag( + super::cloud_view::LocalFolderDrag { path: drag_path }, + |drag, _, _, cx| { + cx.new(|_| { + super::cloud_view::DragLabel( + drag.path + .file_name() + .unwrap_or_default() + .to_string_lossy() + .into_owned(), + ) + }) + }, + ) .drag_over::(|s, _, _, _| { s.bg(gpui::rgb(pal().select_border)) }) @@ -1087,26 +836,203 @@ fn gallery_sections(ws: &Workspace) -> Vec<(String, String, Vec) -> impl IntoElement { - use super::library_geo::MapSlot; - let entries: Vec<_> = gallery_sections(ws) +/// A photo on the world map, from either room: the local library's +/// entry, or a Schist Cloud asset on the page. +#[derive(Clone)] +enum MapPhoto { + Local(super::library::Entry), + Cloud(Box), +} + +/// The photos on show with a valid position. +type LocatedPhotos = Vec<(MapPhoto, (f64, f64))>; + +impl MapPhoto { + fn key(&self) -> String { + match self { + MapPhoto::Local(e) => e.path.display().to_string(), + MapPhoto::Cloud(a) => a.id.clone(), + } + } + fn name(&self) -> String { + match self { + MapPhoto::Local(e) => e + .path + .file_name() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_default(), + MapPhoto::Cloud(a) => a.name.clone(), + } + } +} + +/// The photos on show with a valid position, plus how many are still +/// being read (local EXIF probes) and how many have none. +fn map_photos(ws: &Workspace) -> (LocatedPhotos, usize, usize) { + let mut located = Vec::new(); + let mut pending = 0; + let mut unlocated = 0; + if ws.cloud.show { + // The whole scope's located photos, fetched separately from the + // page; the page total says how many have no fix. + for asset in &ws.cloud.map_assets { + if let Some(l) = asset + .location + .as_ref() + .filter(|l| super::library_geo::valid_position(l.latitude, l.longitude)) + { + located.push(( + MapPhoto::Cloud(Box::new(asset.clone())), + (l.latitude, l.longitude), + )); + } + } + let total = ws.cloud.total as usize; + if ws.cloud.map_loading && ws.cloud.map_assets.is_empty() { + pending = total; + } else { + unlocated = total.saturating_sub(located.len()); + } + } else { + for entry in gallery_sections(ws).into_iter().flat_map(|(_, _, e)| e) { + if let Some(pos) = photo_position(ws, &entry.path) { + located.push((MapPhoto::Local(entry), pos)); + } else if ws.library.positions.contains_key(&entry.path) { + unlocated += 1; + } else { + pending += 1; + } + } + } + (located, pending, unlocated) +} + +fn map_photo_selected(ws: &Workspace, photo: &MapPhoto) -> bool { + match photo { + MapPhoto::Local(e) => ws.library.is_selected(&e.path), + MapPhoto::Cloud(a) => ws.cloud.selected.iter().any(|id| id == &a.id), + } +} + +/// The marker's photos the strip under the map shows. +fn map_strip_photos(ws: &Workspace) -> Vec { + let (located, _, _) = map_photos(ws); + let wanted: FxHashSet = if ws.cloud.show { + ws.cloud.map_photos.iter().cloned().collect() + } else { + ws.library + .map_photos + .iter() + .map(|p| p.display().to_string()) + .collect() + }; + located .into_iter() - .flat_map(|(_, _, entries)| entries) - .collect(); - let located = entries - .iter() - .filter(|e| photo_position(ws, &e.path).is_some()) - .count(); - let pending = entries + .map(|(photo, _)| photo) + .filter(|photo| wanted.contains(&photo.key())) + .collect() +} + +/// A marker was clicked: its photos fill the strip, the first is +/// selected, and a double-click on a lone photo opens it. +fn map_pick(ws: &mut Workspace, photos: &[MapPhoto], open: bool, cx: &mut Context) { + let Some(first) = photos.first() else { + return; + }; + match first { + MapPhoto::Local(entry) => { + ws.library.map_photos = photos + .iter() + .filter_map(|p| match p { + MapPhoto::Local(e) => Some(e.path.clone()), + MapPhoto::Cloud(_) => None, + }) + .collect(); + ws.library.select_single(entry.path.clone()); + if open { + ws.open_from_gallery(entry.path.clone(), cx); + } + } + MapPhoto::Cloud(asset) => { + ws.cloud.map_photos = photos.iter().map(MapPhoto::key).collect(); + ws.cloud_select_single(asset.id.clone()); + if open { + ws.cloud_open((**asset).clone(), cx); + } + } + } +} + +/// A marker was right-clicked: the room's own menu for its photos. +fn map_cluster_context(ws: &mut Workspace, photos: &[MapPhoto], at: Point) { + let paths: Vec = photos .iter() - .filter(|e| !ws.library.positions.contains_key(&e.path)) - .count(); - let unlocated = entries.len() - located - pending; - let selected_paths: FxHashSet<_> = ws.library.map_photos.iter().collect(); - let details: Vec<_> = entries - .into_iter() - .filter(|e| selected_paths.contains(&e.path) && photo_position(ws, &e.path).is_some()) + .filter_map(|p| match p { + MapPhoto::Local(e) => Some(e.path.clone()), + MapPhoto::Cloud(_) => None, + }) .collect(); + if paths.is_empty() { + let ids = photos.iter().map(MapPhoto::key).collect(); + ws.cloud.context = Some((at, super::cloud::CloudContext::Cluster(ids))); + ws.library.context = None; + } else { + ws.library.context = Some((at, super::library::GalleryContext::MapCluster(paths))); + ws.cloud.context = None; + } +} + +/// A photo's preview for a marker or the strip: the local thumbnail +/// pipeline, or the cloud page's fetched thumbnail. +fn map_photo_preview( + ws: &Workspace, + photo: &MapPhoto, + width: f32, + height: f32, + cx: &mut Context, +) -> gpui::AnyElement { + match photo { + MapPhoto::Local(entry) => photo_preview(ws, entry, width, height, cx).into_any_element(), + MapPhoto::Cloud(asset) => { + let thumb = ws + .cloud + .thumbnails + .get(&asset.id) + .map(|(_, image)| image.clone()); + div() + .w(px(width)) + .h(px(height)) + .flex_none() + .overflow_hidden() + .bg(gpui::rgb(pal().chrome_bg)) + .child(match thumb { + Some(image) => img(image) + .size_full() + .object_fit(gpui::ObjectFit::Contain) + .into_any_element(), + None => div() + .size_full() + .flex() + .items_center() + .justify_center() + .text_size(px(10.0)) + .text_color(gpui::rgb(pal().text_dim)) + .child("Photo") + .into_any_element(), + }) + .into_any_element() + } + } +} + +fn world_map(ws: &mut Workspace, cx: &mut Context) -> impl IntoElement { + use super::library_geo::MapSlot; + if ws.cloud.show { + ws.cloud_map_refresh(); + } + let (located_photos, pending, unlocated) = map_photos(ws); + let located = located_photos.len(); + let details = map_strip_photos(ws); let mut status = format!("{located} photos on map · {unlocated} without location"); if pending > 0 { status.push_str(&format!(" · Reading locations: {pending} remaining")); @@ -1126,7 +1052,11 @@ fn world_map(ws: &mut Workspace, cx: &mut Context) -> impl IntoElemen .child(div().text_size(px(11.0)).text_color(gpui::rgb(pal().text_dim)).child(status))) .child(div().px_2().pb_2().text_size(px(11.0)).text_color(gpui::rgb(pal().text_dim)) .child(if located == 0 && pending == 0 { - "No photos with GPS locations in this view. Add geotagged photos or change the gallery filters." + if ws.cloud.show { + "No photos with GPS locations here. Pick another folder or bucket, or clear the search." + } else { + "No photos with GPS locations in this view. Add geotagged photos or change the gallery filters." + } } else { "Drag to pan · Scroll to zoom · Click a marker to see its photos · Double-click a photo to edit" })) @@ -1140,10 +1070,11 @@ fn world_map(ws: &mut Workspace, cx: &mut Context) -> impl IntoElemen .gap_2() .p_2() .overflow_x_scroll(); - for entry in details { - let selected = ws.library.is_selected(&entry.path); - let path = entry.path.clone(); - let preview = photo_preview(ws, &entry, 86.0, 62.0, cx); + for photo in details { + let selected = map_photo_selected(ws, &photo); + let preview = map_photo_preview(ws, &photo, 86.0, 62.0, cx); + let name = photo.name(); + let pick = vec![photo.clone()]; strip = strip.child( div() .flex() @@ -1163,23 +1094,24 @@ fn world_map(ws: &mut Workspace, cx: &mut Context) -> impl IntoElemen .on_mouse_down( MouseButton::Left, cx.listener(move |ws, ev: &MouseDownEvent, _, cx| { - ws.library.select_single(path.clone()); - if ev.click_count >= 2 { - ws.open_from_gallery(path.clone(), cx); + // Picking from the strip keeps the strip: + // the marker's photos stay listed. + let strip = if ws.cloud.show { + None + } else { + Some(ws.library.map_photos.clone()) + }; + let cloud_strip = ws.cloud.map_photos.clone(); + map_pick(ws, &pick, ev.click_count >= 2, cx); + match strip { + Some(paths) => ws.library.map_photos = paths, + None => ws.cloud.map_photos = cloud_strip, } cx.notify(); }), ) .child(preview) - .child( - div().text_size(px(10.0)).truncate().child( - entry - .path - .file_name() - .map(|s| s.to_string_lossy().into_owned()) - .unwrap_or_default(), - ), - ), + .child(div().text_size(px(10.0)).truncate().child(name)), ); } view = view @@ -1201,6 +1133,7 @@ fn world_map(ws: &mut Workspace, cx: &mut Context) -> impl IntoElemen false, |ws, cx| { ws.library.map_photos.clear(); + ws.cloud.map_photos.clear(); cx.notify(); }, cx, @@ -1275,11 +1208,10 @@ fn prepare_photo_markers( bounds: Bounds, cx: &mut Context, ) -> Vec<(Point, gpui::AnyElement)> { - let entries: Vec<_> = gallery_sections(ws) - .into_iter() - .flat_map(|(_, _, entries)| entries) - .filter_map(|e| photo_position(ws, &e.path).map(|pos| (e, pos))) - .collect(); + let (entries, _, _) = map_photos(ws); + // Marker previews name the thumbnails they need; the loader picks + // them up alongside the page's. + ws.cloud.map_wanted.clear(); let points: Vec<_> = entries .iter() .enumerate() @@ -1303,28 +1235,27 @@ fn prepare_photo_markers( let (i, x, y) = cluster[0]; let entry = &entries[i].0; let mut seen = FxHashSet::default(); - let paths: Vec<_> = cluster + let photos: Vec = cluster .iter() - .map(|(i, _, _)| entries[*i].0.path.clone()) - .filter(|path| seen.insert(path.clone())) + .map(|(i, _, _)| entries[*i].0.clone()) + .filter(|photo| seen.insert(photo.key())) .collect(); - let context_paths = paths.clone(); - let count = paths.len(); - let active = paths.iter().any(|p| ws.library.is_selected(p)); - let preview = photo_preview(ws, entry, 56.0, 42.0, cx); + let context_photos = photos.clone(); + let count = photos.len(); + let active = photos.iter().any(|p| map_photo_selected(ws, p)); + if let MapPhoto::Cloud(asset) = entry { + ws.cloud.map_wanted.insert(asset.id.clone()); + } + let preview = map_photo_preview(ws, entry, 56.0, 42.0, cx); let label = if count > 1 { format!("{count} photos") } else { - entry - .path - .file_name() - .map(|s| s.to_string_lossy().into_owned()) - .unwrap_or_default() + entry.name() }; let marker = div() .id(SharedString::from(format!( "map-pin-{}-{cluster_index}", - entry.path.display() + entry.key() ))) .flex() .flex_col() @@ -1337,11 +1268,7 @@ fn prepare_photo_markers( cx.listener(move |ws, ev: &MouseDownEvent, _, cx| { cx.stop_propagation(); ws.library.world_map.end_drag(); - ws.library.map_photos = paths.clone(); - ws.library.select_single(paths[0].clone()); - if count == 1 && ev.click_count >= 2 { - ws.open_from_gallery(paths[0].clone(), cx); - } + map_pick(ws, &photos, count == 1 && ev.click_count >= 2, cx); cx.notify(); }), ) @@ -1350,10 +1277,7 @@ fn prepare_photo_markers( cx.listener(move |ws, ev: &MouseDownEvent, _, cx| { cx.stop_propagation(); ws.library.world_map.end_drag(); - ws.library.context = Some(( - ev.position, - super::library::GalleryContext::MapCluster(context_paths.clone()), - )); + map_cluster_context(ws, &context_photos, ev.position); cx.notify(); }), ) @@ -1405,113 +1329,46 @@ fn grid(ws: &mut Workspace, cx: &mut Context) -> impl IntoElement { let selected: Vec = ws.library.selected.clone(); let sections = gallery_sections(ws); let scanning = ws.library.scanning; - let grid_entity = cx.entity(); - let mut column = div() - .id("gallery-grid") - .flex() - .flex_col() - .flex_grow() - .min_h(px(0.0)) - .overflow_y_scroll() - .track_scroll(&ws.library.grid_scroll) - .bg(gpui::rgb(pal().grid_bg)) - .p_2() - // Record the viewport rectangle, so keyboard navigation can - // work out columns per row, the reveal logic can keep the - // selection on screen, and the cells' visibility probes know - // what "on screen" means. The canvas sits inside the scrolled - // content, so its bounds scroll along with it — subtract the - // scroll offset to get back to window coordinates, the space - // every cell's own bounds are reported in. - .child( - canvas( - { - let grid_entity = grid_entity.clone(); - move |bounds, _window, cx| { - grid_entity.update(cx, |ws, _| { - let offset = ws.library.grid_scroll.offset(); - ws.library.grid_bounds = gpui::Bounds { - origin: bounds.origin - offset, - size: bounds.size, - }; - }); - } - }, - move |_, _, window, _| { - // ⌘-wheel (Ctrl elsewhere) over the grid resizes the - // thumbnails, as ⌘-wheel zooms a canvas. It has to - // win over the container's own scrolling, which runs - // in the bubble phase — so take it in capture and - // stop it there. - let grid_entity = grid_entity.clone(); - window.on_mouse_event(move |ev: &gpui::ScrollWheelEvent, phase, _w, cx| { - if phase != gpui::DispatchPhase::Capture - || !(ev.modifiers.platform || ev.modifiers.control) - { - return; - } - let dy = wheel_pixels(ev); - let took = grid_entity.update(cx, |ws, cx| { - if !ws.library.grid_bounds.contains(&ev.position) { - return false; - } - ws.nudge_gallery_thumb_px(dy); - cx.notify(); - true - }); - if took { - cx.stop_propagation(); - } - }); - }, - ) - .absolute() - .size_full(), - ); + let access: chrome::GridAccess = |ws| &mut ws.library.grid; + let mut column = grid_column("gallery-grid", &ws.library.grid, access, cx); if sections.is_empty() { // Say why the grid is bare, rather than showing a void: a // bucket may simply be empty, a scan may be running, or the // watched folders may hold nothing Schist can decode. - column = column.child( - div() - .p_4() - .text_size(px(12.0)) - .text_color(gpui::rgb(pal().text_dim)) - .child( - match ws - .library - .bucket_filter - .and_then(|i| ws.library.buckets.get(i)) - { - Some(_) if ws.library.search_results.is_some() => { - "Nothing in this bucket matches the search. Escape clears it \ + column = column.child(empty_note( + match ws + .library + .bucket_filter + .and_then(|i| ws.library.buckets.get(i)) + { + Some(_) if ws.library.search_results.is_some() => { + "Nothing in this bucket matches the search. Escape clears it \ to show the whole bucket." - } - None if ws.library.search_results.is_some() => { - "Nothing matches the search. Escape clears it." - } - Some(bucket) if bucket.is_smart() => { - "Nothing matches this bucket's rule yet — matches appear as \ + } + None if ws.library.search_results.is_some() => { + "Nothing matches the search. Escape clears it." + } + Some(bucket) if bucket.is_smart() => { + "Nothing matches this bucket's rule yet — matches appear as \ photos are indexed. Dragging photos in works too." - } - Some(_) => { - "This bucket is empty. Drag photos onto its row in the sidebar \ + } + Some(_) => { + "This bucket is empty. Drag photos onto its row in the sidebar \ to add them." - } - None if ws.library.person_filter.is_some() => { - "Nothing here yet. Faces appear as photos are indexed; \ + } + None if ws.library.person_filter.is_some() => { + "Nothing here yet. Faces appear as photos are indexed; \ click a photo, then a face, to say who it is." - } - None if scanning => "Scanning folders\u{2026}", - None => { - "No photos found in the watched folders. Images Schist can open \ + } + None if scanning => "Scanning folders\u{2026}", + None => { + "No photos found in the watched folders. Images Schist can open \ (PNG, JPEG, WebP, TIFF, HEIC, camera raws, PSD, Affinity) \ appear here; \ sub-folders are scanned six levels deep." - } - }, - ), - ); + } + }, + )); } // Virtualisation: only rows near the viewport build real cells — // a cell is ~20 elements with listeners, and a big library built @@ -1522,17 +1379,12 @@ fn grid(ws: &mut Workspace, cx: &mut Context) -> impl IntoElement { // keep-on-screen scroll even when it is far away. const HEADER_ESTIMATE: f32 = 41.0; let lead = ws.library.lead_selected().cloned(); - let columns = { - let width = f32::from(ws.library.grid_bounds.size.width); - // p_2 padding both sides, gap_2 between cells — the keyboard - // navigation's formula, so rows agree with up/down arrows. - (((width - 16.0 + 8.0) / (cell + 8.0)).floor() as usize).max(1) - }; - let view_h = f32::from(ws.library.grid_bounds.size.height); + let columns = ws.library.grid.columns(cell); + let view_h = f32::from(ws.library.grid.bounds.size.height); // The first frame has no recorded viewport yet: build everything // once, and virtualise from the second frame on. let (win_top, win_bottom) = if view_h > 0.0 { - let scroll_y = -f32::from(ws.library.grid_scroll.offset().y); + let scroll_y = -f32::from(ws.library.grid.handle.offset().y); (scroll_y - view_h, scroll_y + 2.0 * view_h) } else { (f32::MIN, f32::MAX) @@ -1540,32 +1392,11 @@ fn grid(ws: &mut Workspace, cx: &mut Context) -> impl IntoElement { let mut content_y = 8.0; // the column's p_2 top padding for (title, subtitle, entries) in sections { let detail = if subtitle.is_empty() { - format!("{} photos", entries.len()) + chrome::photo_count(entries.len()) } else { format!("{subtitle} — {}", entries.len()) }; - column = column.child( - div() - .flex() - .flex_row() - .items_center() - .gap_2() - .pt_2() - .pb_1() - .child( - div() - .text_size(px(13.0)) - .text_color(gpui::rgb(pal().header)) - .child(title), - ) - .child( - div() - .text_size(px(10.0)) - .text_color(gpui::rgb(pal().text_dim)) - .child(detail), - ), - ); - column = column.child(div().h(px(1.0)).mb_2().bg(gpui::rgb(pal().cell_edge))); + column = column.child(section_header(title, detail)); content_y += HEADER_ESTIMATE; let mut body = div().flex().flex_col(); // Consecutive off-screen rows collapse into one spacer, so a @@ -1582,102 +1413,22 @@ fn grid(ws: &mut Workspace, cx: &mut Context) -> impl IntoElement { hidden += cell + 8.0; continue; } - if hidden > 0.0 { - body = body.child(div().h(px(hidden))); - hidden = 0.0; - } - let mut row = div().flex().flex_row().gap_2().mb_2(); - for entry in row_entries { - row = row.child(cell_element(ws, entry.clone(), cell, &selected, cx)); - } - body = body.child(row); - } - if hidden > 0.0 { - body = body.child(div().h(px(hidden))); - } - column = column.child(body); - } - // The scrollbar gpui doesn't paint: a track along the viewport's - // right edge, exact because the thumb reads the scroll handle's - // own extents. Clicking the track jumps there; dragging is - // handled by the wrapper below, so the pointer may wander off the - // twelve-pixel strip mid-drag without dropping the thumb. - let scrollbar = ws - .library - .scrollbar_geometry() - .map(|(inset, thumb_h, travel, max_y)| { - let scroll_y = (-f32::from(ws.library.grid_scroll.offset().y)).clamp(0.0, max_y); - let thumb_top = inset + scroll_y / max_y * travel; - div() - .absolute() - .top_0() - .right_0() - .bottom_0() - .w(px(12.0)) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |ws, ev: &MouseDownEvent, _w, cx| { - let y = - f32::from(ev.position.y) - f32::from(ws.library.grid_bounds.origin.y); - ws.library.scrollbar_grab = - Some(if (thumb_top..thumb_top + thumb_h).contains(&y) { - // Grabbed the thumb: keep the grip point. - y - thumb_top - } else { - // Clicked the track: the thumb jumps - // there, held by its middle. - thumb_h / 2.0 - }); - ws.library.scrollbar_drag_to(f32::from(ev.position.y)); - cx.stop_propagation(); - cx.notify(); - }), - ) - .child( - div() - .absolute() - .top(px(thumb_top)) - .right(px(2.0)) - .w(px(8.0)) - .h(px(thumb_h)) - .rounded_md() - .bg(gpui::rgb(pal().cell_edge)) - .hover(|s| s.bg(gpui::rgb(pal().cell_hover))), - ) - }); - div() - .relative() - .flex() - .flex_col() - .flex_grow() - .min_h(px(0.0)) - // Shrinkable: a flex item's minimum width is its content's, and - // a row of cells is a fixed width, so without this the grid - // refuses to give up room to the AI panel beside it and pushes - // it off the right edge. The column count follows the width - // the next frame. - .min_w(px(0.0)) - .overflow_hidden() - .on_mouse_move(cx.listener(|ws, ev: &gpui::MouseMoveEvent, _w, cx| { - if ws.library.scrollbar_grab.is_none() { - return; + if hidden > 0.0 { + body = body.child(div().h(px(hidden))); + hidden = 0.0; } - if ev.pressed_button == Some(MouseButton::Left) { - ws.library.scrollbar_drag_to(f32::from(ev.position.y)); - cx.notify(); - } else { - // The button went up somewhere we never heard about. - ws.library.scrollbar_grab = None; + let mut row = div().flex().flex_row().gap_2().mb_2(); + for entry in row_entries { + row = row.child(cell_element(ws, entry.clone(), cell, &selected, cx)); } - })) - .on_mouse_up( - MouseButton::Left, - cx.listener(|ws, _ev: &gpui::MouseUpEvent, _w, _cx| { - ws.library.scrollbar_grab = None; - }), - ) - .child(column) - .children(scrollbar) + body = body.child(row); + } + if hidden > 0.0 { + body = body.child(div().h(px(hidden))); + } + column = column.child(body); + } + grid_frame(column, &ws.library.grid, access, cx) } fn cell_element( @@ -1689,6 +1440,7 @@ fn cell_element( ) -> impl IntoElement { use super::library::{GalleryContext, GalleryDrag}; let thumb = ws.library.thumb(&entry); + let failed = ws.library.thumb_failed(&entry.path); // The drag ghost shows the square being carried, so it wants the // same picture the cell shows. let ghost_thumb = thumb.clone(); @@ -1707,374 +1459,145 @@ fn cell_element( } else { vec![entry.path.clone()] }; - let inner = cell - 10.0; - div() - .id(SharedString::from(format!("cell-{}", entry.path.display()))) - .flex() - .flex_col() - .items_center() - .justify_center() - .w(px(cell)) - .h(px(cell)) - .flex_none() - .relative() - // The visibility probe: at paint time it knows the cell's real - // rectangle, and a cell within a viewport's height of the - // screen is what queues its decode and stamps its thumbnail in - // use. Building the element (this function) must stay free — - // the whole grid builds every frame. - .child( - canvas( - move |bounds, _window, cx| { - probe_entity.update(cx, |ws, cx| { - // One viewport of margin either side, so - // scrolling meets thumbnails, not placeholders. - let mut near = ws.library.grid_bounds; - near.origin.y -= near.size.height; - near.size.height *= 3.0; - if near.intersects(&bounds) && ws.library.note_visible(&probe_entry) { - ws.kick_thumb_loader(cx); - } - }); - }, - |_, _, _, _| {}, - ) - .absolute() - .size_full(), + cell_frame( + SharedString::from(format!("cell-{}", entry.path.display())), + cell, + is_selected, + thumb, + failed, + entry.edited, + ) + // The visibility probe: at paint time it knows the cell's real + // rectangle, and a cell within a viewport's height of the + // screen is what queues its decode and stamps its thumbnail in + // use. Building the element (this function) must stay free — + // the whole grid builds every frame. + .child( + canvas( + move |bounds, _window, cx| { + probe_entity.update(cx, |ws, cx| { + // One viewport of margin either side, so + // scrolling meets thumbnails, not placeholders. + let mut near = ws.library.grid.bounds; + near.origin.y -= near.size.height; + near.size.height *= 3.0; + if near.intersects(&bounds) && ws.library.note_visible(&probe_entry) { + ws.kick_thumb_loader(cx); + } + }); + }, + |_, _, _, _| {}, ) - .rounded_sm() - .bg(gpui::rgb(if is_selected { - pal().select_fill - } else { - pal().grid_bg - })) - .border_2() - .border_color(gpui::rgb(if is_selected { - pal().select_border - } else { - pal().cell_edge - })) - .cursor_pointer() - .hover(move |s| { - if is_selected { - s + .absolute() + .size_full(), + ) + .on_drag( + GalleryDrag { paths: drag_paths }, + move |drag, _offset, _window, cx| { + // What the drag carries, in case it leaves the window + // and the platform's own drag-and-drop takes it on. + let carried = drag.paths.clone(); + drag_entity.update(cx, |ws, _| ws.library.dragging = Some(carried)); + let label = if drag.paths.len() == 1 { + drag_path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "1 photo".into()) } else { - s.border_color(gpui::rgb(pal().cell_hover)) + format!("{} photos", drag.paths.len()) + }; + let thumb = ghost_thumb.clone(); + let count = drag.paths.len(); + cx.new(|_| DragGhost { + label, + thumb, + count, + size: cell, + }) + }, + ) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |ws, ev: &MouseDownEvent, _w, cx| { + let path = click_path.clone(); + if ev.modifiers.platform || ev.modifiers.control { + // ⌘-click: in or out, keeping the rest. + ws.library.toggle_selected(path); + } else if ev.modifiers.shift { + ws.gallery_select_range_to(path); + } else if ev.click_count >= 2 { + ws.library.select_single(path.clone()); + ws.open_from_gallery(path, cx); + } else if !ws.library.is_selected(&click_path) { + // A plain press on an unselected photo selects it — + // and on a selected one keeps the selection, so a + // drag can carry the lot. + ws.library.select_single(path); } - }) - .on_drag( - GalleryDrag { paths: drag_paths }, - move |drag, _offset, _window, cx| { - // What the drag carries, in case it leaves the window - // and the platform's own drag-and-drop takes it on. - let carried = drag.paths.clone(); - drag_entity.update(cx, |ws, _| ws.library.dragging = Some(carried)); - let label = if drag.paths.len() == 1 { - drag_path - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| "1 photo".into()) - } else { - format!("{} photos", drag.paths.len()) - }; - let thumb = ghost_thumb.clone(); - let count = drag.paths.len(); - cx.new(|_| DragGhost { - label, - thumb, - count, - size: cell, - }) - }, - ) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |ws, ev: &MouseDownEvent, _w, cx| { - let path = click_path.clone(); - if ev.modifiers.platform || ev.modifiers.control { - // ⌘-click: in or out, keeping the rest. - ws.library.toggle_selected(path); - } else if ev.modifiers.shift { - ws.gallery_select_range_to(path); - } else if ev.click_count >= 2 { - ws.library.select_single(path.clone()); - ws.open_from_gallery(path, cx); - } else if !ws.library.is_selected(&click_path) { - // A plain press on an unselected photo selects it — - // and on a selected one keeps the selection, so a - // drag can carry the lot. - ws.library.select_single(path); - } - ws.library.context = None; - cx.notify(); - }), - ) - .on_mouse_down( - MouseButton::Right, - cx.listener(move |ws, ev: &MouseDownEvent, _w, cx| { - // Right-click acts on the selection when it lands in - // it, on this photo alone otherwise. - if !ws.library.is_selected(&context_path) { - ws.library.select_single(context_path.clone()); - } - ws.library.context = - Some((ev.position, GalleryContext::Photo(context_path.clone()))); - cx.notify(); - }), - ) - .children(is_lead.then(|| { - // The lead cell reports where it landed, for the - // keyboard's scroll-into-view. - let cell_entity = cx.entity(); - canvas( - move |bounds, _window, cx| { - cell_entity.update(cx, |ws, _| ws.library.selected_bounds = Some(bounds)); - }, - |_, _, _, _| {}, - ) - .absolute() - .size_full() - })) - .children(thumb.map(|t| img(t).max_w(px(inner)).max_h(px(inner)))) - .children(ws.library.thumb_failed(&entry.path).then(|| { - div() - .text_size(px(10.0)) - .text_color(gpui::rgb(pal().text_dim)) - .child("no preview") - })) - .children(entry.edited.then(|| { - // Picasa's little brush: a corner badge saying this photo - // carries an edit. - div() - .absolute() - .bottom(px(3.0)) - .left(px(3.0)) - .px_1() - .rounded_sm() - .bg(gpui::rgb(pal().green)) - .text_size(px(9.0)) - .text_color(gpui::rgb(0xFFFFFF)) - .child("edited") - })) -} - -/// The ghost that rides the pointer during a drag: the picked-up -/// photo's whole square when its thumbnail is in memory (with a count -/// badge for a multi-drag), the old name pill only when it is not. -struct DragGhost { - label: String, - thumb: Option>, - count: usize, - size: f32, -} - -impl gpui::Render for DragGhost { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - let Some(thumb) = self.thumb.clone() else { - return div() - .px_2() - .py_1() - .rounded_md() - .bg(gpui::rgb(pal().select_border)) - .text_color(gpui::rgb(0xFFFFFF)) - .text_size(px(11.0)) - .child(SharedString::from(self.label.clone())) - .into_any_element(); - }; - let inner = self.size - 10.0; - div() - .w(px(self.size)) - .h(px(self.size)) - .relative() - .flex() - .items_center() - .justify_center() - .rounded_sm() - .bg(gpui::rgb(pal().grid_bg)) - .border_2() - .border_color(gpui::rgb(pal().select_border)) - .opacity(0.85) - .child(img(thumb).max_w(px(inner)).max_h(px(inner))) - .children((self.count > 1).then(|| { - div() - .absolute() - .top(px(-6.0)) - .right(px(-6.0)) - .min_w(px(18.0)) - .h(px(18.0)) - .px_1() - .flex() - .items_center() - .justify_center() - .rounded_full() - .bg(gpui::rgb(pal().select_border)) - .text_color(gpui::rgb(0xFFFFFF)) - .text_size(px(10.0)) - .child(format!("{}", self.count)) - })) - .into_any_element() - } + ws.library.context = None; + cx.notify(); + }), + ) + .on_mouse_down( + MouseButton::Right, + cx.listener(move |ws, ev: &MouseDownEvent, _w, cx| { + // Right-click acts on the selection when it lands in + // it, on this photo alone otherwise. + if !ws.library.is_selected(&context_path) { + ws.library.select_single(context_path.clone()); + } + ws.library.context = Some((ev.position, GalleryContext::Photo(context_path.clone()))); + ws.cloud.context = None; + cx.notify(); + }), + ) + .children(is_lead.then(|| lead_probe(|ws| &mut ws.library.grid, cx))) } -/// The bottom tray: selection details and the green Edit button on the -/// left, the photo count and size slider on the right. -fn tray(ws: &mut Workspace, cx: &mut Context) -> impl IntoElement { +/// What the tray says about the local gallery: the lead photo's name +/// and Edit button, the selection size, the content filter's toll. +pub(super) fn tray_info(ws: &Workspace) -> TrayInfo { let selected = ws.library.selected_entry().cloned(); - let count = ws.library.photo_count(); - let thumb_px = ws.library.thumb_px; - let ratio = (thumb_px - 80.0) / 160.0; let name = selected .as_ref() .and_then(|e| e.path.file_name()) .map(|n| n.to_string_lossy().into_owned()); - div() - // Its own id scope: the viewer's header has an Edit button too. - .id("gallery-tray") - .flex() - .flex_row() - .items_center() - .gap_3() - .h(px(40.0)) - .flex_none() - .px_2() - .bg(gpui::rgb(pal().tray_bg)) - .border_t_1() - .border_color(gpui::rgb(pal().chrome_edge)) - .children(selected.as_ref().map(|entry| { - let open = entry.path.clone(); - gallery_button( - "Edit", - true, - move |ws, _w, cx| ws.open_from_gallery(open.clone(), cx), - cx, - ) - })) - .children(selected.as_ref().map(|entry| { - // The quick look — the photo big, its faces to name. - let view = entry.path.clone(); - gallery_button( - "View", - false, - move |ws, _w, cx| ws.open_viewer(view.clone(), cx), - cx, - ) - })) - .children(name.map(|name| { - div() - .text_size(px(12.0)) - .text_color(gpui::rgb(pal().text)) - .child(name) - })) - .children((ws.library.selected.len() > 1).then(|| { - div() - .text_size(px(11.0)) - .text_color(gpui::rgb(pal().text_dim)) - .child(format!("{} selected", ws.library.selected.len())) - })) - .children(selected.as_ref().is_some_and(|e| e.edited).then(|| { - div() - .text_size(px(11.0)) - .text_color(gpui::rgb(pal().text_dim)) - .child("edited — versions kept beside the file") - })) - .children({ - let hidden = if ws.view.gallery_hide_nsfw { - ws.library.flagged_count() - } else { - 0 - }; - (hidden > 0).then(|| { - div() - .text_size(px(11.0)) - .text_color(gpui::rgb(pal().text_dim)) - .child(format!("{hidden} hidden by the content filter")) - }) - }) - .child(div().flex_grow()) - // The editor's status bar is hidden here, so the tray carries the - // status line — otherwise an import's outcome lands nowhere. - .child( - div() - .max_w(px(420.0)) - .truncate() - .text_size(px(11.0)) - .text_color(gpui::rgb(pal().text_dim)) - .child(ws.status.clone()), - ) - .child( - div() - .text_size(px(11.0)) - .text_color(gpui::rgb(pal().text_dim)) - .child(format!("{count} photos")), - ) - .child(size_slider(ratio, cx)) -} - -/// The thumbnail-size slider, drawn on the gallery's own palette so it -/// does not import the editor theme's near-black track onto the tray. -fn size_slider(ratio: f32, cx: &mut Context) -> impl IntoElement { - const WIDTH: f32 = 110.0; - let entity = cx.entity(); - let set = move |ws: &mut Workspace, r: f32| { - ws.set_gallery_thumb_px(80.0 + r * 160.0); + let mut notes = Vec::new(); + if selected.as_ref().is_some_and(|e| e.edited) { + notes.push("edited — versions kept beside the file".to_string()); + } + let hidden = if ws.view.gallery_hide_nsfw { + ws.library.flagged_count() + } else { + 0 }; - let down = set; - let moved = set; - div() - .relative() - .w(px(WIDTH)) - .h(px(12.0)) - .flex_none() - .rounded_sm() - .bg(gpui::rgb(pal().chrome_edge)) - .child( - div() - .absolute() - .left_0() - .top_0() - .bottom_0() - .w(px(WIDTH * ratio.clamp(0.0, 1.0))) - .rounded_sm() - .bg(gpui::rgb(pal().select_border)), - ) - .child( - gpui::canvas( - move |bounds, _window, cx| { - entity.update(cx, |ws, _| { - ws.record_slider_bounds("gallery-thumb-size", bounds) - }); + if hidden > 0 { + notes.push(format!("{hidden} hidden by the content filter")); + } + TrayInfo { + edit: selected.clone().map(|entry| { + let open = entry.path.clone(); + Box::new( + move |ws: &mut Workspace, _w: &mut Window, cx: &mut Context| { + ws.open_from_gallery(open.clone(), cx) }, - |_, _, _, _| {}, + ) as Box)> + }), + extra: selected.map(|entry| { + ( + "View", + Box::new( + move |ws: &mut Workspace, _w: &mut Window, cx: &mut Context| { + ws.open_viewer(entry.path.clone(), cx) + }, + ) as chrome::TrayAction, ) - .absolute() - .size_full(), - ) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |ws, ev: &gpui::MouseDownEvent, _w, cx| { - ws.begin_slider("gallery-thumb-size", ratio); - if let Some(r) = ws.slider_ratio("gallery-thumb-size", ev.position) { - down(ws, r); - } - cx.notify(); - }), - ) - .on_mouse_move(cx.listener(move |ws, ev: &gpui::MouseMoveEvent, _w, cx| { - if ev.pressed_button == Some(MouseButton::Left) - && ws.dragging_slider("gallery-thumb-size") - { - if let Some(r) = ws.slider_ratio("gallery-thumb-size", ev.position) { - moved(ws, r); - cx.notify(); - } - } - })) - .on_mouse_up( - MouseButton::Left, - cx.listener(|ws, _ev: &gpui::MouseUpEvent, _w, _cx| { - ws.end_slider("gallery-thumb-size"); - }), - ) + }), + name, + selected: ws.library.selected.len(), + notes, + count: chrome::photo_count(ws.library.photo_count()), + } } /// The camera picker. Several mounted cameras ask which one; none says @@ -2640,7 +2163,11 @@ pub(crate) fn map_filter_dialog( cx: &mut Context, ) -> impl IntoElement { let selection = ws.library.map.selection; - let filtering = ws.library.map_filter.is_some(); + let filtering = if ws.cloud.show { + ws.cloud.query.filters.bounds.is_some() + } else { + ws.library.map_filter.is_some() + }; let status = match (&selection, &ws.library.map.selection_name) { (Some(_), Some(name)) => format!( "Apply shows only photos taken in {name}; photos without an EXIF position hide." @@ -2882,11 +2409,27 @@ pub(crate) fn bucket_name_dialog( query: String, photos: usize, editing: Option, + cloud: bool, cx: &mut Context, ) -> impl IntoElement { - let name_fallback = match editing.and_then(|i| ws.library.buckets.get(i)) { - Some(bucket) => bucket.name.clone(), - None => format!("Bucket {}", ws.library.buckets.len() + 1), + // The same dialog serves a Schist Cloud bucket: the name and the + // rule (search text, drawn area) go to the provider instead. + let cloud_target = cloud + .then_some(ws.cloud.form_target.as_ref()) + .flatten() + .and_then(|(id, _)| ws.cloud.buckets.iter().find(|b| &b.id == id)) + .cloned(); + let is_edit = editing.is_some() || cloud_target.is_some(); + let name_fallback = if cloud { + match &cloud_target { + Some(bucket) => bucket.name.clone(), + None => format!("Bucket {}", ws.cloud.buckets.len() + 1), + } + } else { + match editing.and_then(|i| ws.library.buckets.get(i)) { + Some(bucket) => bucket.name.clone(), + None => format!("Bucket {}", ws.library.buckets.len() + 1), + } }; let name_field = bucket_field("bucket-name", name, name_fallback, ws, cx); let query_field = bucket_field( @@ -2965,7 +2508,7 @@ pub(crate) fn bucket_name_dialog( cx, )) .child(crate::ui::button( - if editing.is_some() { "Save" } else { "Create" }, + if is_edit { "Save" } else { "Create" }, true, |ws, _w, cx| { ws.commit_focused_field(); @@ -2974,6 +2517,7 @@ pub(crate) fn bucket_name_dialog( query, photos, editing, + cloud, }) = ws.modal.clone() else { return; @@ -2992,6 +2536,17 @@ pub(crate) fn bucket_name_dialog( .unwrap_or_else(|| "Selected Area".to_string()), ) }); + if cloud { + let bounds = area.map(|(b, _)| schist_cloud::Bounds { + south: b.south, + north: b.north, + west: b.west, + east: b.east, + }); + ws.cloud_save_bucket(name, query, bounds); + ws.close_modal(cx); + return; + } let index = match editing { Some(index) => index, None => ws.library.add_bucket(name.clone()), @@ -3004,10 +2559,11 @@ pub(crate) fn bucket_name_dialog( }, cx, )); - let title = if editing.is_some() { - "Edit Bucket" - } else { - "New Bucket" + let title = match (cloud, is_edit) { + (true, true) => "Edit Cloud Bucket", + (true, false) => "New Cloud Bucket", + (false, true) => "Edit Bucket", + (false, false) => "New Bucket", }; crate::ui::modal_frame(title, 580.0, body, actions) } @@ -3260,6 +2816,85 @@ fn gallery_context_menu( ); } } + GalleryContext::LocalFolder(root) => { + if ws.cloud.account.is_some() { + let upload = root.clone(); + row( + "Upload to Schist Cloud\u{2026}".into(), + &mut rows, + cx, + std::rc::Rc::new(move |ws, _w, cx| { + ws.open_modal( + Modal::Cloud { + kind: "upload-folder", + fields: vec![ + ("cloud-folder", "Folder".into(), String::new()), + ("cloud-path", "".into(), upload.display().to_string()), + ], + }, + cx, + ); + }), + ); + sep(&mut rows); + } + let reveal = root.clone(); + row( + "Reveal in file manager".into(), + &mut rows, + cx, + std::rc::Rc::new(move |_ws, _w, _cx| { + super::library_ops::reveal_in_file_manager(&reveal); + }), + ); + row( + "Stop watching this folder".into(), + &mut rows, + cx, + std::rc::Rc::new(move |ws, _w, cx| { + ws.gallery_remove_folder(&root.clone(), cx); + }), + ); + } + GalleryContext::AddFolder => { + row( + "Watch a folder on this computer\u{2026}".into(), + &mut rows, + cx, + std::rc::Rc::new(|ws, window, cx| ws.gallery_add_folder(window, cx)), + ); + row( + format!( + "{} New Schist Cloud folder\u{2026}", + super::cloud_view::CLOUD_GLYPH + ), + &mut rows, + cx, + std::rc::Rc::new(|ws, _w, cx| super::cloud_view::new_cloud_folder(ws, cx)), + ); + } + GalleryContext::NewBucket => { + row( + "New bucket on this computer\u{2026}".into(), + &mut rows, + cx, + std::rc::Rc::new(|ws, _w, cx| { + // Born holding the selection, so "new bucket from + // these" is the dialog's Create away. + let selected = ws.library.selected.clone(); + ws.gallery_new_bucket(selected, cx); + }), + ); + row( + format!( + "{} New Schist Cloud bucket\u{2026}", + super::cloud_view::CLOUD_GLYPH + ), + &mut rows, + cx, + std::rc::Rc::new(|ws, _w, cx| super::cloud_view::new_cloud_bucket(ws, cx)), + ); + } GalleryContext::Bucket(index) => { // The group actions act on everything the bucket holds: // the hand-picked photos and the smart rule's matches. diff --git a/crates/app/src/workspace/mod.rs b/crates/app/src/workspace/mod.rs index 39a4432c..aef753ea 100644 --- a/crates/app/src/workspace/mod.rs +++ b/crates/app/src/workspace/mod.rs @@ -36,6 +36,10 @@ mod ai; mod ai; mod chrome; mod clipboard; +pub(crate) mod cloud; +pub(crate) mod cloud_generation; +pub(crate) mod cloud_people; +pub(crate) mod cloud_view; mod colormgmt; mod commands; mod compose; @@ -45,6 +49,7 @@ mod docs; mod edit_ops; mod export; mod filters; +pub(crate) mod gallery_chrome; mod image_ops; mod input; mod layers_panel; @@ -85,7 +90,7 @@ mod viewport; #[cfg(not(target_arch = "wasm32"))] pub(crate) use library_geo::MapSlot; #[cfg(not(target_arch = "wasm32"))] -pub(crate) use library_people_view::{people_models_dialog, person_name_dialog}; +pub(crate) use library_people_view::person_name_dialog; #[cfg(not(target_arch = "wasm32"))] pub(crate) use library_view::map_element; #[cfg(not(target_arch = "wasm32"))] @@ -201,6 +206,7 @@ pub struct ModelDownload { } pub struct Workspace { + pub(crate) cloud: cloud::CloudState, pub registry: PluginRegistry, pub editor: EditorState, pub doc: Option, @@ -449,22 +455,7 @@ impl Workspace { } #[cfg(target_arch = "wasm32")] { - false - } - } - - /// Whether the gallery's search box — or the viewer's name field — - /// is taking typing, for the key context. Always false on the web, - /// with the gallery itself. - pub fn gallery_typing(&self) -> bool { - #[cfg(not(target_arch = "wasm32"))] - { - self.gallery_search_active() - || (self.library.open && self.focused_field == Some("face-name")) - } - #[cfg(target_arch = "wasm32")] - { - false + self.cloud.show } } } @@ -974,6 +965,11 @@ pub enum UpdateProgress { // plumbing matches exhaustively on every target. #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub enum Modal { + CloudGenerate, + Cloud { + kind: &'static str, + fields: Vec<(&'static str, String, String)>, + }, ImageSize { width: u32, height: u32, @@ -1036,18 +1032,30 @@ pub enum Modal { preview: bool, }, /// Edit ▸ Content-Aware Scale. - ContentAwareScale { width: u32, height: u32 }, + ContentAwareScale { + width: u32, + height: u32, + }, /// Edit ▸ Stroke. Stroke { width: f32, position: schist_core::StrokePosition, }, /// Edit ▸ Fill. - Fill { source: FillSource, opacity: f32 }, + Fill { + source: FillSource, + opacity: f32, + }, /// Select ▸ Modify, which all take one amount. - SelectModify { kind: ModifyKind, amount: f32 }, + SelectModify { + kind: ModifyKind, + amount: f32, + }, /// Select ▸ Color Range. - ColorRange { tolerance: f32, target: Rgba }, + ColorRange { + tolerance: f32, + target: Rgba, + }, /// Photoshop's Color Picker. ColorPicker { target: ColorTarget, @@ -1063,22 +1071,33 @@ pub enum Modal { ConfirmCloseTab, /// An image file dropped on the window while a document is open: /// open it in its own tab, or place it as a new layer? - DropImage { path: PathBuf }, + DropImage { + path: PathBuf, + }, /// Folders dropped on the window: open every image inside as a tab, /// or watch them in the gallery? `images` is what a scan found in /// them, so the button can say how many tabs that would be. - DropFolders { dirs: Vec, images: usize }, + DropFolders { + dirs: Vec, + images: usize, + }, /// A HEIC file needs the libheif decoder and this machine has none: /// offer to download it (with its LGPL license texts), then retry /// opening `path`. - HeifSupport { path: PathBuf }, + HeifSupport { + path: PathBuf, + }, /// More than one camera is reachable: ask which to import from. - CameraImport { sources: Vec }, + CameraImport { + sources: Vec, + }, /// Import options for one camera: the navigable OpenStreetMap view /// where a boundary can be drawn (only photos whose EXIF position /// falls inside it import). The map's own state lives on the /// library, not here — it changes every pointer move. - CameraImportOptions { source: ImportSource }, + CameraImportOptions { + source: ImportSource, + }, /// A device import failed (a locked iPhone, most often): say so in /// a dialog with the way forward, and offer to try again with the /// same source and boundary. Only ever constructed on macOS, where @@ -1092,7 +1111,9 @@ pub enum Modal { /// A release newer than this build. On macOS and Windows it offers /// to install itself and restart; everywhere else it points at the /// release page, since the copy came from a package manager. - UpdateAvailable { update: crate::update::Update }, + UpdateAvailable { + update: crate::update::Update, + }, /// The third-party plugin manager. PluginManager, /// Neural Filters model downloads. @@ -1136,12 +1157,12 @@ pub enum Modal { /// The gallery's offer to install the two Search models, with the /// licences to agree to first. Desktop-only, like the gallery. SearchModels, - /// The same offer for the two People models — the face detector - /// and the face recogniser. - PeopleModels, /// Rename one of the gallery's people (`index` into the people /// list); a name somebody else has merges the two. - PersonName { index: usize, name: String }, + PersonName { + index: usize, + name: String, + }, /// Save one gallery photo as a flat image: format, quality where the /// format takes one, and a scale to shrink it by. `size` is the /// source's pixel size when it could be read up front, so the @@ -1173,6 +1194,9 @@ pub enum Modal { query: String, photos: Vec, editing: Option, + /// A Schist Cloud bucket rather than a local one; which one, and + /// its rule's scope, live in `cloud.form_target` and `form_scope`. + cloud: bool, }, /// The full new-document dialog: everything a fresh document needs, /// asked up front as Photoshop does. @@ -1321,6 +1345,7 @@ impl Workspace { cx: &mut Context, ) -> Self { let mut ws = Workspace { + cloud: cloud::CloudState::default(), registry, editor: EditorState::default(), doc: None, @@ -1476,6 +1501,7 @@ impl Workspace { } }) .detach(); + ws.cloud_start(cx); ws } } diff --git a/crates/app/src/workspace/modals.rs b/crates/app/src/workspace/modals.rs index 43a655a7..e5c224ff 100644 --- a/crates/app/src/workspace/modals.rs +++ b/crates/app/src/workspace/modals.rs @@ -247,7 +247,8 @@ impl Workspace { || id == "bucket-name" || id == "bucket-query" || id == "face-name" - || id == "person-name"; + || id == "person-name" + || id.starts_with("cloud-"); let hex = id == "cp-hex"; // The caret belongs to the textual fields; keep it on the rails // in case the buffer changed underneath it. @@ -343,6 +344,25 @@ impl Workspace { pub(super) fn commit_field_value(&mut self, id: &'static str) { let buffer = self.field_buffer.clone(); + if id == "cloud-generation-input" { + if let Some(id) = self.cloud.generation.editing.clone() { + self.cloud + .generation + .values + .insert(id, schist_cloud::generation::Input::Text(buffer)); + } + return; + } + if id.starts_with("cloud-") { + self.update_modal(|modal| { + if let Modal::Cloud { fields, .. } = modal { + if let Some((_, _, value)) = fields.iter_mut().find(|(key, _, _)| *key == id) { + *value = buffer; + } + } + }); + return; + } if id == "layer-name" { self.update_modal(|m| { if let Modal::LayerProperties { name, .. } = m { @@ -427,6 +447,7 @@ impl Workspace { .map(|d| d.width as f32 / d.height.max(1) as f32) .unwrap_or(1.0); self.update_modal(|m| match m { + Modal::Cloud {..} | Modal::CloudGenerate => {}, Modal::ImageSize { width, height, @@ -491,7 +512,6 @@ impl Workspace { | Modal::NewFilePicker | Modal::MapFilter | Modal::SearchModels - | Modal::PeopleModels | Modal::PersonName { .. } | Modal::SaveImageAs { .. } | Modal::BatchProcess { .. } diff --git a/crates/app/src/workspace/recovery.rs b/crates/app/src/workspace/recovery.rs index 4ce83aaf..1fb52107 100644 --- a/crates/app/src/workspace/recovery.rs +++ b/crates/app/src/workspace/recovery.rs @@ -19,6 +19,7 @@ impl Workspace { /// Write a recovery snapshot for every document with unsaved changes. /// Returns true when at least one snapshot was written. pub fn autosave(&mut self) -> bool { + self.cloud_checkpoint(); let dirty: Vec<&Document> = self .doc .iter() diff --git a/crates/app/src/workspace/render.rs b/crates/app/src/workspace/render.rs index 5ea708d1..31b90678 100644 --- a/crates/app/src/workspace/render.rs +++ b/crates/app/src/workspace/render.rs @@ -624,7 +624,7 @@ impl Render for Workspace { } #[cfg(target_arch = "wasm32")] { - unreachable!("the gallery is not compiled into the web build") + super::cloud_view::browser_gallery(self, cx) } } else { div() @@ -756,11 +756,8 @@ impl Render for Workspace { cx.notify(); })) .on_action(cx.listener(|ws, _: &CancelGesture, _w, cx| { - // Escape leaves the gallery's search, a face being - // named, or the viewer before anything else — they are - // the innermost things open. - #[cfg(not(target_arch = "wasm32"))] - if ws.gallery_open() && ws.gallery_escape(cx) { + // Escape leaves the active gallery field or viewer first. + if ws.gallery_escape(cx) { return; } ws.cancel_gesture(cx); @@ -769,11 +766,7 @@ impl Render for Workspace { // Enter in the gallery opens the selected photo — the // binding takes the keystroke before any key listener // could, so the branch lives here. - #[cfg(not(target_arch = "wasm32"))] - if ws.gallery_open() && !ws.library.search_active { - if let Some(path) = ws.library.lead_selected().cloned() { - ws.open_from_gallery(path, cx); - } + if ws.gallery_enter(cx) { return; } ws.commit_gesture(cx); diff --git a/crates/app/src/workspace/services.rs b/crates/app/src/workspace/services.rs index 3f0a1d24..cce030a0 100644 --- a/crates/app/src/workspace/services.rs +++ b/crates/app/src/workspace/services.rs @@ -67,6 +67,13 @@ impl Workspace { Ok(path) => format!("Installed {} to {}", spec.name, path.display()).into(), Err(e) => format!("{}: {e}", spec.name).into(), }; + #[cfg(not(target_arch = "wasm32"))] + if super::library_people::PEOPLE_MODELS.contains(&id) + && schist_neural::installed(id) + { + // The position pass may have finished before the models arrived. + ws.kick_thumb_loader(cx); + } cx.notify(); }) .ok(); diff --git a/crates/cloud/Cargo.toml b/crates/cloud/Cargo.toml new file mode 100644 index 00000000..cae504f5 --- /dev/null +++ b/crates/cloud/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "schist-cloud" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +serde.workspace = true +serde_json.workspace = true +rmpv = { version = "1", features = ["with-serde"] } +rmp-serde = "1" +serde_bytes = "0.11" +url = "2" +uuid = { version = "1", features = ["v4", "js"] } +tokio = { version = "1", features = ["sync", "macros"] } +futures.workspace = true +schist-document.workspace = true +schist-core.workspace = true +schist-color.workspace = true +schist-codec-psd.workspace = true + +sha2 = "0.10" +chrono = "0.4" +web-time = "1" + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +tempfile = "3" +ureq = { version = "3", default-features = false, features = ["rustls", "json"] } +tokio-tungstenite = { version = "0.28", features = ["rustls-tls-webpki-roots"] } +tokio = { version = "1", features = ["rt", "net", "time"] } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +js-sys = "0.3" +gloo-timers = { version = "0.3", features = ["futures"] } +web-sys = { version = "0.3", features = ["Window", "Location", "MessageEvent", "Event", "CloseEvent", "WebSocket", "BinaryType", "Request", "RequestInit", "RequestMode", "RequestCredentials", "RequestRedirect", "Response", "Headers", "ReadableStream", "ReadableStreamDefaultReader", "AbortController", "AbortSignal"] } + +[target.'cfg(target_arch = "wasm32")'.dev-dependencies] +wasm-bindgen-test = "0.3" diff --git a/crates/cloud/src/auth.rs b/crates/cloud/src/auth.rs new file mode 100644 index 00000000..354d2900 --- /dev/null +++ b/crates/cloud/src/auth.rs @@ -0,0 +1,360 @@ +use crate::protocol::*; +use anyhow::{anyhow, ensure, Context, Result}; +use serde::Deserialize; +use std::{ + io::{Read, Write}, + net::{SocketAddr, TcpListener, TcpStream}, + path::{Path, PathBuf}, + time::{Duration, Instant}, +}; +use url::Url; + +pub fn secure_url(raw: &str, scheme: &str) -> Result { + let url = Url::parse(raw)?; + ensure!( + url.scheme() == scheme + && url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none(), + "Expected an absolute {scheme} URL without credentials" + ); + Ok(url) +} +pub fn domain(raw: &str) -> Result { + let raw = raw.trim(); + ensure!(!raw.is_empty(), "Enter a domain"); + let url = secure_url( + &if raw.contains("://") { + raw.into() + } else { + format!("https://{raw}") + }, + "https", + )?; + ensure!( + url.path() == "/" && url.query().is_none() && url.fragment().is_none(), + "Enter only a domain, without a path" + ); + Ok(url.origin().ascii_serialization()) +} +/// One agent for the process: its connection pool keeps HTTPS sessions +/// open between thumbnail fetches and downloads, where a fresh agent +/// per request paid a TLS handshake (and a root-store load) every time. +pub fn agent() -> ureq::Agent { + static AGENT: std::sync::OnceLock = std::sync::OnceLock::new(); + AGENT + .get_or_init(|| { + ureq::Agent::config_builder() + .timeout_global(Some(Duration::from_secs(30))) + .max_redirects(0) + .build() + .into() + }) + .clone() +} +#[derive(Deserialize)] +struct Discovery { + authentication_url: String, + code_exchange_url: String, +} +pub struct Login { + pub domain: String, + pub browser_url: String, + pub exchange_url: String, + state: String, + listener: TcpListener, + callback_file: PathBuf, + started: Instant, +} +impl Login { + pub fn discover(raw: &str, dir: &Path) -> Result { + let domain = domain(raw)?; + let discovery: Discovery = agent() + .get(format!("{domain}/.schist/auth-urls.json")) + .call()? + .body_mut() + .read_json()?; + let mut browser = secure_url(&discovery.authentication_url, "https")?; + secure_url(&discovery.code_exchange_url, "https")?; + let state = uuid::Uuid::new_v4().to_string(); + let query: Vec<_> = browser + .query_pairs() + .filter(|(k, _)| k != "state") + .map(|(k, v)| (k.into_owned(), v.into_owned())) + .collect(); + browser.set_query(None); + browser + .query_pairs_mut() + .extend_pairs(query) + .append_pair("state", &state); + let listener = TcpListener::bind("127.0.0.1:0")?; + listener.set_nonblocking(true)?; + private_directory(dir)?; + let callback_file = dir.join(format!("cloud-callback-{state}")); + private_write( + &callback_file, + listener.local_addr()?.port().to_string().as_bytes(), + )?; + Ok(Self { + domain, + browser_url: browser.into(), + exchange_url: discovery.code_exchange_url, + state, + listener, + callback_file, + started: Instant::now(), + }) + } + pub fn poll(&self) -> Result> { + ensure!( + self.started.elapsed() < Duration::from_secs(600), + "Sign-in expired; try again" + ); + match self.listener.accept() { + Ok((stream, _)) => { + stream.set_read_timeout(Some(Duration::from_secs(1)))?; + let mut data = String::new(); + stream.take(8193).read_to_string(&mut data)?; + ensure!(data.len() <= 8192, "Callback too long"); + Ok(Some(data)) + } + Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => Ok(None), + Err(e) => Err(e.into()), + } + } + pub fn exchange(&self, callback: &str) -> Result { + let (state, code) = callback_parts(callback)?; + ensure!(state == self.state, "Sign-in state mismatch"); + let credentials = exchange( + &self.exchange_url, + serde_json::json!({"response_type":"code","code":code,"state":state,"schist_spec_version":1}), + )?; + Ok(Account { + domain: self.domain.clone(), + exchange_url: self.exchange_url.clone(), + credentials, + }) + } +} +impl Drop for Login { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.callback_file); + } +} +fn callback_parts(raw: &str) -> Result<(String, String)> { + let u = Url::parse(raw)?; + ensure!( + u.scheme() == "schist" + && u.host_str() == Some("ig-callback") + && (u.path().is_empty() || u.path() == "/"), + "Invalid cloud callback" + ); + let values = |key: &str| -> Result { + let values: Vec<_> = u + .query_pairs() + .filter(|(k, _)| k == key) + .map(|(_, v)| v.into_owned()) + .collect(); + ensure!( + values.len() == 1 && !values[0].is_empty(), + "Invalid callback {key}" + ); + Ok(values[0].clone()) + }; + Ok((values("state")?, values("code")?)) +} +/// Linux/Windows launch a second process for custom URI schemes. Forward only to +/// the flow identified by its random state, over loopback; never launch another editor. +pub fn forward_callback(raw: &str, dir: &Path) -> Result<()> { + let (state, _) = callback_parts(raw)?; + let state = uuid::Uuid::parse_str(&state)?.to_string(); + let port: u16 = + std::fs::read_to_string(dir.join(format!("cloud-callback-{state}")))?.parse()?; + let mut stream = TcpStream::connect_timeout( + &SocketAddr::from(([127, 0, 0, 1], port)), + Duration::from_secs(2), + )?; + stream.set_write_timeout(Some(Duration::from_secs(2)))?; + stream.write_all(raw.as_bytes())?; + Ok(()) +} +fn exchange(url: &str, body: serde_json::Value) -> Result { + secure_url(url, "https")?; + let c: Credentials = agent().post(url).send_json(body)?.body_mut().read_json()?; + ensure!( + !c.access_token.is_empty() && !c.refresh_token.is_empty() && c.expires_at.is_finite(), + "Invalid credentials" + ); + secure_url(&c.generation_endpoint_url, "https")?; + secure_url(&c.logout_url, "https")?; + secure_url( + c.workspace_websocket_url + .as_deref() + .context("Provider has no cloud workspace endpoint")?, + "wss", + )?; + Ok(c) +} +pub fn refresh(account: &Account) -> Result { + exchange( + &account.exchange_url, + serde_json::json!({"response_type":"refresh_token","refresh_token":account.credentials.refresh_token,"schist_spec_version":1}), + ) +} +pub fn logout(account: &Account) -> Result<()> { + secure_url(&account.credentials.logout_url, "https")?; + agent().delete(&account.credentials.logout_url).call()?; + Ok(()) +} +pub fn private_directory(dir: &Path) -> Result<()> { + std::fs::create_dir_all(dir)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?; + } + Ok(()) +} +pub fn private_write(path: &Path, bytes: &[u8]) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| anyhow!("Missing state directory"))?; + private_directory(parent)?; + let mut file = tempfile::NamedTempFile::new_in(parent)?; + file.write_all(bytes)?; + file.as_file().sync_all()?; + file.persist(path)?; + Ok(()) +} +pub fn download(url: &str) -> Result> { + download_limited(url, 512 * 1024 * 1024) +} +pub fn download_limited(url: &str, limit: u64) -> Result> { + Ok(download_response(url, limit)?.bytes) +} +pub struct DownloadResponse { + pub bytes: Vec, + pub content_type: Option, + pub content_disposition: Option, +} +pub fn download_response(url: &str, limit: u64) -> Result { + #[cfg(test)] + if url.starts_with("http://127.0.0.1:") { + return read_download(url, limit); + } + secure_url(url, "https")?; + read_download(url, limit) +} +fn read_download(url: &str, limit: u64) -> Result { + let mut response = agent().get(url).call()?; + let header = |name| { + response + .headers() + .get(name) + .and_then(|v| v.to_str().ok()) + .map(str::to_owned) + }; + let content_type = header("content-type"); + let content_disposition = header("content-disposition"); + let bytes = response + .body_mut() + .with_config() + .limit(limit) + .read_to_vec()?; + Ok(DownloadResponse { + bytes, + content_type, + content_disposition, + }) +} +/// Whether an HTTP failure was the network's doing — no connection, a +/// timeout, a gateway standing in for an absent server — rather than +/// the server's answer. +pub fn network_error(error: &anyhow::Error) -> bool { + match error.downcast_ref::() { + Some(ureq::Error::StatusCode(code)) => matches!(*code, 408 | 429 | 500..=599), + Some(ureq::Error::Io(_)) + | Some(ureq::Error::Timeout(_)) + | Some(ureq::Error::ConnectionFailed) + | Some(ureq::Error::HostNotFound) => true, + Some(_) => false, + None => error.downcast_ref::().is_some(), + } +} +pub fn upload(url: &str, mime: &str, bytes: &[u8]) -> Result<()> { + secure_url(url, "https")?; + agent() + .put(url) + .config() + .timeout_global(Some(Duration::from_secs(180))) + .build() + .header("Content-Type", mime) + .send(bytes)?; + Ok(()) +} +pub async fn upload_async(url: &str, mime: &str, bytes: &[u8]) -> Result<()> { + upload(url, mime, bytes) +} +pub async fn download_limited_async(url: &str, limit: u64) -> Result> { + download_limited(url, limit) +} +pub async fn logout_async(account: &Account) -> Result<()> { + logout(account) +} + +/// Rejected credentials need user action; retrying cannot restore access. +pub fn refresh_rejected(error: &anyhow::Error) -> bool { + matches!( + error.downcast_ref::(), + Some(ureq::Error::StatusCode(401 | 403)) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn origins() { + assert_eq!(domain("schist.app").unwrap(), "https://schist.app"); + for s in ["http://a", "https://u:p@a", "a/b", "a?x", "a#x", ""] { + assert!(domain(s).is_err(), "{s}"); + } + } + #[test] + fn callbacks_require_unique_state_and_code() { + assert!(callback_parts("schist://ig-callback?state=a&code=b").is_ok()); + assert!(callback_parts("schist://ig-callback?state=a&state=b&code=c").is_err()); + } + #[test] + fn callback_is_routed_to_its_original_login() { + let dir = tempfile::tempdir().unwrap(); + let state = uuid::Uuid::new_v4().to_string(); + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let path = dir.path().join(format!("cloud-callback-{state}")); + private_write( + &path, + listener.local_addr().unwrap().port().to_string().as_bytes(), + ) + .unwrap(); + let login = Login { + domain: "https://schist.app".into(), + browser_url: String::new(), + exchange_url: String::new(), + state: state.clone(), + listener, + callback_file: path.clone(), + started: Instant::now(), + }; + let wrong = format!( + "schist://ig-callback?state={}&code=code", + uuid::Uuid::new_v4() + ); + assert!(forward_callback(&wrong, dir.path()).is_err()); + let callback = format!("schist://ig-callback?state={state}&code=code"); + forward_callback(&callback, dir.path()).unwrap(); + assert_eq!(login.poll().unwrap(), Some(callback)); + drop(login); + assert!(!path.exists()); + } +} diff --git a/crates/cloud/src/browser_auth.rs b/crates/cloud/src/browser_auth.rs new file mode 100644 index 00000000..f26074d2 --- /dev/null +++ b/crates/cloud/src/browser_auth.rs @@ -0,0 +1,346 @@ +use crate::{Account, Credentials}; +use anyhow::{anyhow, ensure, Result}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::{ + cell::RefCell, + rc::Rc, + sync::atomic::{AtomicBool, Ordering}, + time::Duration, +}; +use url::Url; +use wasm_bindgen::{closure::Closure, JsCast, JsValue}; +use wasm_bindgen_futures::JsFuture; +use web_sys::{ + MessageEvent, RequestCredentials, RequestInit, RequestMode, RequestRedirect, Response, Window, +}; + +pub const CLIENT_ORIGIN: &str = "https://try.schist.app"; +pub fn window() -> Result { + web_sys::window().ok_or_else(|| anyhow!("Browser window unavailable")) +} +pub fn secure_url(raw: &str, scheme: &str) -> Result { + let url = Url::parse(raw)?; + ensure!( + url.scheme() == scheme + && url.host_str() == Some("schist.app") + && url.port_or_known_default() == Some(443) + && url.username().is_empty() + && url.password().is_none(), + "Browser cloud connections are restricted to schist.app" + ); + Ok(url) +} +pub fn domain(raw: &str) -> Result { + let raw = raw.trim(); + ensure!( + raw == "schist.app" || raw == "https://schist.app" || raw == "https://schist.app/", + "Browser cloud connections are restricted to schist.app" + ); + Ok("https://schist.app".into()) +} +#[derive(Debug)] +pub struct HttpStatus(pub u16); +impl std::fmt::Display for HttpStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Cloud HTTP {}", self.0) + } +} +impl std::error::Error for HttpStatus {} +fn js_error(_: JsValue) -> anyhow::Error { + anyhow!("Browser cloud request failed") +} +pub struct DownloadResponse { + pub bytes: Vec, + pub content_type: Option, + pub content_disposition: Option, +} +pub async fn fetch( + method: &str, + url: &str, + content_type: Option<&str>, + body: Option<&[u8]>, + bearer: Option<&str>, + limit: u64, +) -> Result { + secure_url(url, "https")?; + ensure!( + window()?.location().origin().map_err(js_error)? == CLIENT_ORIGIN, + "Cloud is available at try.schist.app" + ); + let abort = web_sys::AbortController::new().map_err(js_error)?; + struct Abort(web_sys::AbortController); + impl Drop for Abort { + fn drop(&mut self) { + self.0.abort(); + } + } + let _abort = Abort(abort.clone()); + let options = RequestInit::new(); + options.set_method(method); + options.set_mode(RequestMode::Cors); + options.set_credentials(RequestCredentials::Omit); + options.set_redirect(RequestRedirect::Error); + options.set_signal(Some(&abort.signal())); + let headers = web_sys::Headers::new().map_err(js_error)?; + if let Some(mime) = content_type { + headers.set("Content-Type", mime).map_err(js_error)?; + } + if let Some(token) = bearer { + headers + .set("Authorization", &format!("Bearer {token}")) + .map_err(js_error)?; + } + options.set_headers(&headers); + if let Some(body) = body { + options.set_body(&js_sys::Uint8Array::from(body)); + } + crate::runtime::timeout(Duration::from_secs(60), async { + let response: Response = JsFuture::from(window()?.fetch_with_str_and_init(url, &options)) + .await + .map_err(js_error)? + .dyn_into() + .map_err(js_error)?; + if !response.ok() { + return Err(HttpStatus(response.status()).into()); + } + let content_type = response.headers().get("content-type").map_err(js_error)?; + let content_disposition = response + .headers() + .get("content-disposition") + .map_err(js_error)?; + let mut bytes = Vec::new(); + if let Some(body) = response.body() { + let reader: web_sys::ReadableStreamDefaultReader = body + .get_reader() + .dyn_into() + .map_err(|e| js_error(e.into()))?; + loop { + let chunk = JsFuture::from(reader.read()).await.map_err(js_error)?; + if js_sys::Reflect::get(&chunk, &"done".into()) + .map_err(js_error)? + .as_bool() + == Some(true) + { + break; + } + let data = js_sys::Uint8Array::new( + &js_sys::Reflect::get(&chunk, &"value".into()).map_err(js_error)?, + ); + if bytes.len() as u64 + u64::from(data.length()) > limit { + let _ = reader.cancel(); + anyhow::bail!("Cloud response exceeds size limit"); + } + bytes.extend(data.to_vec()); + } + } + Ok(DownloadResponse { + bytes, + content_type, + content_disposition, + }) + }) + .await? +} +pub async fn download_response(url: &str, limit: u64) -> Result { + fetch("GET", url, None, None, None, limit).await +} +pub async fn download_limited_async(url: &str, limit: u64) -> Result> { + Ok(download_response(url, limit).await?.bytes) +} +/// Whether a fetch failed for want of a network rather than by the +/// server's answer: a status a gateway gives for an absent server, or +/// the browser's own "Failed to fetch" / "NetworkError". +pub fn network_error(error: &anyhow::Error) -> bool { + match error.downcast_ref::() { + Some(HttpStatus(code)) => matches!(*code, 408 | 429 | 500..=599), + None => { + let text = error.to_string().to_ascii_lowercase(); + text.contains("fetch") || text.contains("network") + } + } +} +pub async fn upload_async(url: &str, mime: &str, bytes: &[u8]) -> Result<()> { + fetch("PUT", url, Some(mime), Some(bytes), None, 65536).await?; + Ok(()) +} +pub async fn logout_async(account: &Account) -> Result<()> { + fetch( + "DELETE", + &account.credentials.logout_url, + None, + None, + None, + 65536, + ) + .await?; + Ok(()) +} +async fn exchange(url: &str, body: serde_json::Value) -> Result { + let response = fetch( + "POST", + url, + Some("application/json"), + Some(&serde_json::to_vec(&body)?), + None, + 65536, + ) + .await?; + let c: Credentials = serde_json::from_slice(&response.bytes)?; + ensure!( + !c.access_token.is_empty() && !c.refresh_token.is_empty() && c.expires_at.is_finite(), + "Invalid cloud credentials" + ); + secure_url(&c.logout_url, "https")?; + secure_url(&c.generation_endpoint_url, "https")?; + secure_url( + c.workspace_websocket_url + .as_deref() + .ok_or_else(|| anyhow!("No workspace endpoint"))?, + "wss", + )?; + Ok(c) +} +pub async fn refresh(account: &Account) -> Result { + exchange(&account.exchange_url, serde_json::json!({"response_type":"refresh_token","refresh_token":account.credentials.refresh_token,"schist_spec_version":1})).await +} +// Open synchronously in the click handler, before fetching discovery. +pub struct Login { + popup: Window, +} +impl Drop for Login { + fn drop(&mut self) { + let _ = self.popup.close(); + } +} +impl Login { + pub fn open() -> Result { + let window = window()?; + ensure!( + window.location().origin().map_err(js_error)? == CLIENT_ORIGIN, + "Cloud is available at try.schist.app" + ); + let popup = window + .open_with_url_and_target_and_features( + "about:blank", + "_blank", + "popup,width=520,height=720", + ) + .map_err(js_error)? + .ok_or_else(|| anyhow!("Allow the sign-in popup and try again"))?; + Ok(Self { popup }) + } + pub async fn finish(self, cancel: &AtomicBool) -> Result { + #[derive(Deserialize)] + struct Discovery { + authentication_url: String, + code_exchange_url: String, + browser_authentication: BrowserAuth, + } + #[derive(Deserialize)] + struct BrowserAuth { + method: String, + code_challenge_method: String, + origins: Vec, + } + let bytes = + download_limited_async("https://schist.app/.schist/auth-urls.json", 65536).await?; + let discovery: Discovery = serde_json::from_slice(&bytes)?; + ensure!( + discovery.browser_authentication.method == "post_message" + && discovery.browser_authentication.code_challenge_method == "S256-hex" + && discovery + .browser_authentication + .origins + .iter() + .any(|s| s == CLIENT_ORIGIN), + "Provider does not support this browser sign-in flow" + ); + let mut url = secure_url(&discovery.authentication_url, "https")?; + secure_url(&discovery.code_exchange_url, "https")?; + let state = uuid::Uuid::new_v4().to_string(); + let verifier = format!( + "{}{}", + uuid::Uuid::new_v4().simple(), + uuid::Uuid::new_v4().simple() + ); + let challenge = format!("{:x}", Sha256::digest(verifier.as_bytes())); + url.query_pairs_mut() + .append_pair("state", &state) + .append_pair("return_origin", CLIENT_ORIGIN) + .append_pair("code_challenge", &challenge); + let result = Rc::new(RefCell::new(None)); + let received = result.clone(); + let popup = self.popup.clone(); + let expected_state = state.clone(); + let listener = Closure::new(move |event: MessageEvent| { + if event.origin() != "https://schist.app" { + return; + } + if !event + .source() + .is_some_and(|source| js_sys::Object::is(source.as_ref(), popup.as_ref())) + { + return; + } + let data = event.data(); + let get = |key: &str| { + js_sys::Reflect::get(&data, &key.into()) + .ok() + .and_then(|s| s.as_string()) + }; + if get("type").as_deref() == Some("schist.authorization") + && get("state").as_deref() == Some(&expected_state) + { + if let Some(code) = get("code").filter(|c| !c.is_empty() && c.len() <= 256) { + *received.borrow_mut() = Some(code); + } + } + }); + let window = window()?; + window + .add_event_listener_with_callback("message", listener.as_ref().unchecked_ref()) + .map_err(js_error)?; + struct Listener(Window, Closure); + impl Drop for Listener { + fn drop(&mut self) { + let _ = self.0.remove_event_listener_with_callback( + "message", + self.1.as_ref().unchecked_ref(), + ); + } + } + let _listener = Listener(window, listener); + self.popup + .location() + .set_href(url.as_str()) + .map_err(js_error)?; + let code = crate::runtime::timeout(Duration::from_secs(600), async { + loop { + if let Some(code) = result.borrow_mut().take() { + return Ok(code); + } + ensure!(!cancel.load(Ordering::Relaxed), "Sign-in cancelled"); + ensure!( + !self.popup.closed().map_err(js_error)?, + "Sign-in window closed" + ); + crate::runtime::sleep(Duration::from_millis(100)).await; + } + }) + .await??; + let credentials = exchange(&discovery.code_exchange_url, serde_json::json!({"response_type":"code","code":code,"state":state,"code_verifier":verifier,"schist_spec_version":1})).await?; + Ok(Account { + domain: "https://schist.app".into(), + exchange_url: discovery.code_exchange_url, + credentials, + }) + } +} + +pub fn refresh_rejected(error: &anyhow::Error) -> bool { + matches!( + error.downcast_ref::(), + Some(HttpStatus(401 | 403)) + ) +} diff --git a/crates/cloud/src/browser_tests.rs b/crates/cloud/src/browser_tests.rs new file mode 100644 index 00000000..14988d2d --- /dev/null +++ b/crates/cloud/src/browser_tests.rs @@ -0,0 +1,122 @@ +use crate::{protocol::*, runtime, Account, Client, Credentials, Event}; +use std::time::Duration; +use wasm_bindgen::prelude::*; +use wasm_bindgen_test::wasm_bindgen_test; + +#[wasm_bindgen(inline_js = r#" +let original; +let socket; +let sent = []; +export function install() { + original = globalThis.WebSocket; + globalThis.WebSocket = class { + constructor() { socket = this; this.readyState = 0; this.bufferedAmount = 0; queueMicrotask(() => { this.readyState = 1; this.onopen?.(new Event('open')); }); } + send(bytes) { sent.push(new Uint8Array(bytes)); } + close() { this.readyState = 3; this.onclose?.(new CloseEvent('close', {code:1000})); } + }; +} +export function take() { return sent.shift(); } +export function deliver(bytes) { socket.onmessage(new MessageEvent('message', {data: bytes.slice().buffer})); } +export function restore() { globalThis.WebSocket = original; sent = []; } +"#)] +extern "C" { + fn install(); + fn take() -> JsValue; + fn deliver(bytes: &[u8]); + fn restore(); +} +async fn outgoing() -> Value { + runtime::timeout(Duration::from_secs(2), async { + loop { + let bytes = take(); + if !bytes.is_undefined() { + return rmp_serde::from_slice(&js_sys::Uint8Array::new(&bytes).to_vec()).unwrap(); + } + runtime::sleep(Duration::from_millis(10)).await; + } + }) + .await + .unwrap() +} +fn incoming(value: Value) { + deliver(&encode(&value).unwrap()); +} +#[wasm_bindgen_test(async)] +async fn browser_socket_multiplexes_binary_requests_and_cleans_up() { + install(); + struct Restore; + impl Drop for Restore { + fn drop(&mut self) { + restore(); + } + } + let _restore = Restore; + let client = Client::start(Account { + domain: "https://schist.app".into(), + exchange_url: "https://schist.app/api/auth/exchange".into(), + credentials: Credentials { + access_token: "test".into(), + refresh_token: "test".into(), + expires_at: 9_999_999_999.0, + generation_endpoint_url: "https://schist.app/api/generation".into(), + logout_url: "https://schist.app/api/auth/logout/test".into(), + workspace_websocket_url: Some("wss://schist.app/ws/workspace".into()), + }, + }); + assert_eq!(string(&outgoing().await, "type").unwrap(), "hello"); + incoming(map([("type", "ready".into()), ("protocol", 1.into())])); + runtime::sleep(Duration::from_millis(10)).await; + assert!(matches!( + client.events.try_recv().unwrap(), + Event::Connected + )); + client.handle.watch( + "folders", + WatchQuery::Folders { + query: Default::default(), + }, + ); + client.handle.watch( + "buckets", + WatchQuery::Buckets { + query: Default::default(), + }, + ); + for _ in 0..2 { + assert_eq!(string(&outgoing().await, "type").unwrap(), "subscribe"); + } + let id = client.handle.call( + "document.update", + map([("update", Value::Binary(vec![0, 1, 254, 255]))]), + ); + let wire = outgoing().await; + assert_eq!(string(&wire, "id").unwrap(), id); + assert!(matches!( + field(field(&wire, "params").unwrap(), "update").unwrap(), + Value::Binary(_) + )); + incoming(map([ + ("type", "result".into()), + ("id", id.clone().into()), + ("value", map([])), + ])); + runtime::sleep(Duration::from_millis(10)).await; + assert!( + matches!(client.events.try_recv().unwrap(), Event::Reply { id: reply_id, result: Ok(_) } if reply_id == id) + ); + drop(client); + runtime::sleep(Duration::from_millis(10)).await; +} +#[wasm_bindgen_test] +fn browser_rejects_other_providers() { + assert!(crate::auth::domain("schist.app").is_ok()); + for url in [ + "https://attacker.test", + "https://schist.app.attacker.test", + "https://schist.app:8443", + "https://user@schist.app", + ] { + assert!(crate::auth::secure_url(url, "https").is_err()); + } + assert!(crate::auth::Login::open().is_err()); // test runner is not the hosted editor +} diff --git a/crates/cloud/src/document.rs b/crates/cloud/src/document.rs new file mode 100644 index 00000000..e1dc7a46 --- /dev/null +++ b/crates/cloud/src/document.rs @@ -0,0 +1,2 @@ +//! The desktop client and cloud worker share the same image model. +pub use schist_document::SharedDocument; diff --git a/crates/cloud/src/generation.rs b/crates/cloud/src/generation.rs new file mode 100644 index 00000000..5fe0e177 --- /dev/null +++ b/crates/cloud/src/generation.rs @@ -0,0 +1,303 @@ +//! Image-generation API. Its per-generation socket keeps the legacy +//! slot/chunk wire format; all workspace features still share the MessagePack socket. +use crate::{auth, Account}; +use crate::{ + runtime, + socket::{Message, Socket}, +}; +use anyhow::{anyhow, ensure, Result}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + sync::atomic::{AtomicBool, Ordering}, + time::Duration, +}; + +#[derive(Clone, Debug, Deserialize)] +#[serde(tag = "t", rename_all = "snake_case")] +pub enum Item { + Text { + title: String, + description: String, + required: bool, + id: String, + }, + Select { + title: String, + description: String, + required: bool, + id: String, + multiple: bool, + values: Vec, + }, + LiveTextPreview { + live_preview_url: String, + }, +} +#[derive(Clone, Debug, Deserialize)] +pub struct Choice { + pub id: String, + pub text: String, +} +#[derive(Clone, Debug, Serialize, PartialEq)] +#[serde(untagged)] +pub enum Input { + Text(String), + Multiple(Vec), +} +pub type Inputs = BTreeMap; +#[derive(Clone, Debug, Deserialize)] +pub struct Part { + pub part_name: String, + pub children_count: usize, +} +pub enum Event { + Layout(Vec), + Image { + index: usize, + bytes: Vec, + }, + Complete { + index: usize, + rejected: Option, + }, +} +async fn request( + account: &Account, + method: &str, + url: &str, + inputs: Option<&Inputs>, +) -> Result> { + auth::secure_url(url, "https")?; + #[cfg(not(target_arch = "wasm32"))] + { + let agent = auth::agent(); + let token = format!("Bearer {}", account.credentials.access_token); + let mut response = if method == "POST" { + agent + .post(url) + .header("Authorization", token) + .send_json(inputs)? + } else { + agent.get(url).header("Authorization", token).call()? + }; + Ok(response + .body_mut() + .with_config() + .limit(1024 * 1024) + .read_to_vec()?) + } + #[cfg(target_arch = "wasm32")] + { + let body = inputs.map(serde_json::to_vec).transpose()?; + Ok(auth::fetch( + method, + url, + body.as_ref().map(|_| "application/json"), + body.as_deref(), + Some(&account.credentials.access_token), + 1024 * 1024, + ) + .await? + .bytes) + } +} +pub async fn form(account: &Account) -> Result> { + let items: Vec = serde_json::from_slice( + &request( + account, + "GET", + &account.credentials.generation_endpoint_url, + None, + ) + .await?, + )?; + let mut ids = HashSet::new(); + for item in &items { + match item { + Item::Text { id, .. } | Item::Select { id, .. } => { + ensure!( + !id.is_empty() && ids.insert(id.clone()), + "Invalid generation field ID" + ); + } + Item::LiveTextPreview { live_preview_url } => { + auth::secure_url(live_preview_url, "https")?; + } + } + } + Ok(items) +} +pub async fn preview(account: &Account, url: &str, inputs: &Inputs) -> Result { + Ok(String::from_utf8( + request(account, "POST", url, Some(inputs)).await?, + )?) +} +pub fn validate(items: &[Item], inputs: &Inputs) -> Result<()> { + for item in items { + match item { + Item::Text { + id, + title, + required, + .. + } => { + if *required { + ensure!( + matches!(inputs.get(id),Some(Input::Text(s)) if !s.trim().is_empty()), + "Enter {title}" + ); + } + } + Item::Select { + id, + title, + required, + multiple, + values, + .. + } => { + let selected = match inputs.get(id) { + Some(Input::Text(s)) if !multiple => vec![s.clone()], + Some(Input::Multiple(v)) if *multiple => v.clone(), + None => vec![], + _ => return Err(anyhow!("Invalid value for {title}")), + }; + ensure!(!required || !selected.is_empty(), "Choose {title}"); + ensure!( + selected.iter().all(|id| values.iter().any(|v| &v.id == id)), + "Invalid option for {title}" + ); + } + Item::LiveTextPreview { .. } => {} + } + } + Ok(()) +} +pub async fn generate( + account: &Account, + inputs: &Inputs, + cancel: &AtomicBool, + mut emit: impl FnMut(Event), +) -> Result<()> { + let url = String::from_utf8( + request( + account, + "POST", + &account.credentials.generation_endpoint_url, + Some(inputs), + ) + .await?, + )?; + auth::secure_url(&url, "wss")?; + let mut ws = runtime::timeout(Duration::from_secs(15), Socket::connect(&url)).await??; + let mut slots = None; + let mut terminal = HashSet::new(); + let mut chunks: HashMap> = HashMap::new(); + let mut last = web_time::Instant::now(); + loop { + ensure!(!cancel.load(Ordering::Relaxed), "Generation cancelled"); + ensure!( + last.elapsed() < Duration::from_secs(120), + "Generation timed out" + ); + let message = tokio::select! { + message = ws.next() => message.ok_or_else(|| anyhow!("Generation connection closed"))??, + _ = runtime::sleep(Duration::from_millis(100)) => continue, + }; + last = web_time::Instant::now(); + match message { + Message::Text(text) if slots.is_none() => { + let layout: Vec = serde_json::from_str(&text)?; + let count = layout + .iter() + .try_fold(0usize, |n, p| n.checked_add(p.children_count)) + .ok_or_else(|| anyhow!("Invalid layout"))?; + ensure!(count <= 128, "Generation layout exceeds 128 slots"); + emit(Event::Layout(layout)); + slots = Some(count); + if count == 0 { + break; + } + } + Message::Text(text) => { + let status: Vec = serde_json::from_str(&text)?; + ensure!((1..=2).contains(&status.len()), "Invalid slot status"); + let index = status[0] + .as_u64() + .ok_or_else(|| anyhow!("Invalid slot index"))? + as usize; + ensure!(index < slots.unwrap(), "Slot outside layout"); + let rejected = if status.len() == 2 { + Some( + status[1] + .as_str() + .ok_or_else(|| anyhow!("Invalid rejection reason"))? + .into(), + ) + } else { + None + }; + if terminal.insert(index) { + chunks.remove(&index); + emit(Event::Complete { index, rejected }); + } + if terminal.len() == slots.unwrap() { + break; + } + } + Message::Binary(frame) => { + ensure!( + !frame.is_empty() && slots.is_some(), + "Expected layout before image bytes" + ); + let index = (frame[0] & 127) as usize; + ensure!( + index < slots.unwrap() && !terminal.contains(&index), + "Invalid or completed slot" + ); + let data = chunks.entry(index).or_default(); + ensure!( + data.len() + frame.len() - 1 <= 64 * 1024 * 1024, + "Generated image exceeds 64 MiB" + ); + data.extend_from_slice(&frame[1..]); + if frame[0] & 128 != 0 { + emit(Event::Image { + index, + bytes: chunks.remove(&index).unwrap(), + }); + } + } + Message::Ping => { + ws.flush().await?; + } + Message::Pong => {} + Message::Close(_) => { + return Err(anyhow!("Generation closed before every slot completed")) + } + } + } + let _ = ws.close().await; + Ok(()) +} +#[cfg(test)] +mod tests { + use super::*; + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn required_fields() { + let items = vec![Item::Text { + id: "prompt".into(), + title: "Prompt".into(), + description: String::new(), + required: true, + }]; + assert!(validate(&items, &Inputs::new()).is_err()); + assert!(validate( + &items, + &BTreeMap::from([("prompt".into(), Input::Text("A photo".into()))]) + ) + .is_ok()); + } +} diff --git a/crates/cloud/src/lib.rs b/crates/cloud/src/lib.rs new file mode 100644 index 00000000..162c9aa6 --- /dev/null +++ b/crates/cloud/src/lib.rs @@ -0,0 +1,23 @@ +//! Schist Cloud client with native and browser transports. +#[cfg(not(target_arch = "wasm32"))] +pub mod auth; +#[cfg(target_arch = "wasm32")] +#[path = "browser_auth.rs"] +pub mod auth; +pub mod document; +pub mod generation; +pub mod multipart; +pub mod protocol; +pub mod runtime; +mod socket; +pub mod transfer; +pub mod transport; +pub use protocol::*; +pub use rmpv::Value; +pub use transport::{Client, Event, Handle, Upload}; +pub use uuid::Uuid; + +#[cfg(all(test, target_arch = "wasm32"))] +wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); +#[cfg(all(test, target_arch = "wasm32"))] +mod browser_tests; diff --git a/crates/cloud/src/multipart.rs b/crates/cloud/src/multipart.rs new file mode 100644 index 00000000..eb6b095d --- /dev/null +++ b/crates/cloud/src/multipart.rs @@ -0,0 +1,348 @@ +//! Bounded-memory uploads. A content/destination key finds the same durable +//! server ticket when a user selects an interrupted file again (24-hour expiry). +use crate::{auth, protocol::*, runtime, Handle}; +use anyhow::{ensure, Result}; +use serde::Deserialize; +use sha2::{Digest, Sha256}; +use std::time::Duration; + +pub const PART_BYTES: usize = 8 * 1024 * 1024; +pub struct ChunkUpload<'a> { + pub name: &'a str, + pub size: u64, + pub mime: &'a str, + pub folder: Option<&'a str>, + pub relative: Option<&'a str>, + pub resume_key: String, +} +#[derive(Deserialize)] +struct Status { + #[serde(default)] + asset: Option, + #[serde(default)] + received: bool, + #[serde(default)] + parts: Vec, +} +#[derive(Deserialize)] +struct Part { + number: u64, + size: u64, +} +fn key( + digest: &[u8], + name: &str, + mime: &str, + folder: Option<&str>, + relative: Option<&str>, +) -> String { + let mut hash = Sha256::new(); + hash.update(digest); + hash.update(serde_json::to_vec(&(name, mime, folder, relative)).expect("string tuple")); + format!("{:x}", hash.finalize()) +} +impl Handle { + pub async fn upload_chunks_async( + &self, + upload: ChunkUpload<'_>, + mut read: impl FnMut(u64, usize) -> Result>, + mut progress: impl FnMut(u64), + ) -> Result { + validate_upload_size(upload.size)?; + let mut fields = vec![ + ("name", upload.name.into()), + ("mime_type", upload.mime.into()), + ("size", upload.size.into()), + ("resume_key", upload.resume_key.into()), + ("mutation_id", uuid::Uuid::new_v4().to_string().into()), + ]; + if let Some(folder) = upload.folder { + fields.push(("folder_id", folder.into())); + } + if let Some(relative) = upload.relative { + fields.push(("relative_path", relative.into())); + } + let ticket = self + .request_async("asset.prepare_multipart", map(fields)) + .await?; + let id = string(&ticket, "upload_id")?; + let part_size = field(&ticket, "part_size")?.as_u64().unwrap_or(0); + ensure!( + part_size == PART_BYTES as u64, + "Unsupported upload chunk size" + ); + let status: Status = parse( + self.request_async( + "asset.multipart_status", + map([ + ("upload_id", id.clone().into()), + ("mutation_id", uuid::Uuid::new_v4().to_string().into()), + ]), + ) + .await?, + )?; + if let Some(asset) = status.asset { + progress(upload.size); + return Ok(asset); + } + let mut done = 0; + if !status.received { + for (index, offset) in (0..upload.size).step_by(PART_BYTES).enumerate() { + let number = index as u64 + 1; + let length = (upload.size - offset).min(part_size) as usize; + if !status + .parts + .iter() + .any(|p| p.number == number && p.size == length as u64) + { + let bytes = read(offset, length)?; + ensure!( + bytes.len() == length, + "The source file changed during upload" + ); + for attempt in 0..3 { + let result = async { + let part = self + .request_async( + "asset.multipart_part", + map([ + ("upload_id", id.clone().into()), + ("part_number", number.into()), + ("mutation_id", uuid::Uuid::new_v4().to_string().into()), + ]), + ) + .await?; + auth::upload_async( + &string(&part, "put_url")?, + "application/octet-stream", + &bytes, + ) + .await + } + .await; + match result { + Ok(()) => break, + Err(error) if attempt == 2 => { + return Err(error.context( + "Select this file again to resume its uploaded parts", + )) + } + Err(_) => runtime::sleep(Duration::from_secs(1 << attempt)).await, + } + } + } + done += length as u64; + progress(done); + } + } + parse( + self.request_async( + "asset.commit_upload", + map([ + ("upload_id", id.into()), + ("mutation_id", uuid::Uuid::new_v4().to_string().into()), + ]), + ) + .await?, + ) + } + #[cfg(not(target_arch = "wasm32"))] + pub async fn upload_path_async( + &self, + path: &std::path::Path, + mime: &str, + folder: Option<&str>, + relative: Option<&str>, + progress: impl FnMut(u64), + ) -> Result { + use std::io::{Read, Seek, SeekFrom}; + let mut file = std::fs::File::open(path)?; + let metadata = file.metadata()?; + validate_upload_size(metadata.len())?; + let modified = metadata.modified()?; + let digest = file_digest(&mut file, &metadata)?; + let name = path.file_name().unwrap_or_default().to_string_lossy(); + let resume_key = key(&digest, &name, mime, folder, relative); + self.upload_chunks_async( + ChunkUpload { + name: &name, + size: metadata.len(), + mime, + folder, + relative, + resume_key, + }, + |offset, length| { + let current = file.metadata()?; + ensure!( + current.len() == metadata.len() && current.modified()? == modified, + "The source file changed; select it again to start a new upload" + ); + file.seek(SeekFrom::Start(offset))?; + let mut bytes = vec![0; length]; + file.read_exact(&mut bytes)?; + Ok(bytes) + }, + progress, + ) + .await + } + pub async fn upload_large_bytes_async(&self, upload: crate::Upload<'_>) -> Result { + ensure!( + upload.asset.is_none(), + "Large collaborative replacements use the document API" + ); + let resume_key = key( + &Sha256::digest(upload.bytes), + upload.name, + upload.mime, + upload.folder, + upload.relative, + ); + self.upload_chunks_async( + ChunkUpload { + name: upload.name, + size: upload.bytes.len() as u64, + mime: upload.mime, + folder: upload.folder, + relative: upload.relative, + resume_key, + }, + |offset, length| Ok(upload.bytes[offset as usize..offset as usize + length].to_vec()), + |_| {}, + ) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn resume_identity_includes_contents_and_destination() { + let digest = Sha256::digest(b"original"); + let original = key( + &digest, + "photo.tiff", + "image/tiff", + Some("folder"), + Some("trip/photo.tiff"), + ); + assert_eq!( + original, + key( + &digest, + "photo.tiff", + "image/tiff", + Some("folder"), + Some("trip/photo.tiff") + ) + ); + assert_ne!( + original, + key( + &Sha256::digest(b"changed"), + "photo.tiff", + "image/tiff", + Some("folder"), + Some("trip/photo.tiff") + ) + ); + assert_ne!( + original, + key( + &digest, + "photo.tiff", + "image/tiff", + Some("other"), + Some("trip/photo.tiff") + ) + ); + assert_ne!( + original, + key( + &digest, + "photo.tiff", + "image/tiff", + Some("folder"), + Some("other/photo.tiff") + ) + ); + } +} + +#[cfg(not(target_arch = "wasm32"))] +fn file_digest(file: &mut std::fs::File, metadata: &std::fs::Metadata) -> Result> { + use std::io::Read; + let mut digest = Sha256::new(); + let mut buffer = vec![0; PART_BYTES]; + let mut hashed = 0u64; + loop { + let length = file.read(&mut buffer)?; + hashed += length as u64; + ensure!( + hashed <= metadata.len(), + "The source file grew during upload preparation" + ); + if length == 0 { + break; + } + digest.update(&buffer[..length]); + } + ensure!( + hashed == metadata.len() && file.metadata()?.modified()? == metadata.modified()?, + "The source file changed during upload preparation" + ); + drop(buffer); + Ok(digest.finalize().to_vec()) +} +#[cfg(not(target_arch = "wasm32"))] +pub fn resume_key_for_path( + path: &std::path::Path, + mime: &str, + folder: Option<&str>, + relative: Option<&str>, +) -> Result { + let mut file = std::fs::File::open(path)?; + let metadata = file.metadata()?; + validate_upload_size(metadata.len())?; + let digest = file_digest(&mut file, &metadata)?; + Ok(key( + &digest, + &path.file_name().unwrap_or_default().to_string_lossy(), + mime, + folder, + relative, + )) +} +pub fn resume_key_for_bytes( + bytes: &[u8], + name: &str, + mime: &str, + folder: Option<&str>, + relative: Option<&str>, +) -> String { + key(&Sha256::digest(bytes), name, mime, folder, relative) +} +#[derive(Deserialize)] +pub struct UploadCapacity { + pub fits: bool, + pub additional_bytes: u64, + pub remaining_bytes: Option, + pub shortfall_bytes: u64, +} +impl UploadCapacity { + pub fn require_space(&self) -> Result<()> { + let format = |bytes: u64| { + if bytes >= 1024 * 1024 * 1024 { + format!("{:.2} GiB", bytes as f64 / (1024f64.powi(3))) + } else if bytes >= 1024 * 1024 { + format!("{:.2} MiB", bytes as f64 / (1024f64.powi(2))) + } else { + format!("{bytes} bytes") + } + }; + ensure!(self.fits,"Not enough cloud storage. This selection needs {}; {} is available. Free up {} or upgrade your plan. No files were uploaded.",format(self.additional_bytes),format(self.remaining_bytes.unwrap_or(0)),format(self.shortfall_bytes)); + Ok(()) + } +} diff --git a/crates/cloud/src/protocol.rs b/crates/cloud/src/protocol.rs new file mode 100644 index 00000000..bb46a3db --- /dev/null +++ b/crates/cloud/src/protocol.rs @@ -0,0 +1,575 @@ +use anyhow::{bail, ensure, Result}; +pub use rmpv::Value; +use serde::{Deserialize, Serialize}; + +pub const DEFAULT_DOMAIN: &str = "schist.app"; +pub const MAX_FRAME: usize = 256 * 1024 * 1024; +/// The provider's per-file upload limit, separate from the wire frame limit. +pub const MAX_SINGLE_UPLOAD_BYTES: u64 = 100 * 1024 * 1024; +pub const MAX_UPLOAD_BYTES: u64 = 5 * 1024 * 1024 * 1024; +pub const IMAGE_MODEL: &str = "schist.image.v1"; + +pub fn validate_upload_size(size: u64) -> Result<()> { + ensure!(size > 0, "File is empty"); + ensure!( + size <= MAX_UPLOAD_BYTES, + "Exceeds the 5 GiB per-file upload limit" + ); + Ok(()) +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Capabilities { + pub document_models: Vec, + pub formats: Vec, + pub max_frame_bytes: u64, + pub max_document_bytes: u64, + pub default_edited_export: String, + pub original_download: bool, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Format { + pub id: String, + pub name: String, + pub extensions: Vec, + pub can_export: bool, + pub runtime_requirement: Option, +} +impl Capabilities { + pub fn from_reply(reply: std::result::Result) -> Result> { + match reply { + Ok(value) => Ok(Some(parse(value)?)), + Err(error) if error.starts_with("method_not_found:") => Ok(None), + Err(error) => bail!(error), + } + } + pub fn frame_limit(&self) -> usize { + self.max_frame_bytes.min(MAX_FRAME as u64) as usize + } + pub fn supports_image_model(&self) -> bool { + self.document_models + .iter() + .any(|model| model == IMAGE_MODEL) + } + pub fn supports_export(&self, extension: &str) -> bool { + self.formats + .iter() + .any(|format| format.can_export && format.extensions.iter().any(|ext| ext == extension)) + } + pub fn check_document(&self, bytes: usize) -> Result<()> { + ensure!( + bytes as u64 <= self.max_document_bytes, + "Shared document exceeds the provider's {} byte limit; edits remain local", + self.max_document_bytes + ); + Ok(()) + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct Credentials { + pub access_token: String, + pub refresh_token: String, + pub expires_at: f64, + pub generation_endpoint_url: String, + pub logout_url: String, + pub workspace_websocket_url: Option, +} +#[derive(Clone, Serialize, Deserialize)] +pub struct Account { + pub domain: String, + pub exchange_url: String, + pub credentials: Credentials, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct Folder { + pub id: String, + pub parent_id: Option, + pub name: String, + pub revision: u64, + /// Assets in this folder and its descendants, supplied with the folder list. + /// Older providers may omit the count; unknown is distinct from empty. + #[serde(default)] + pub asset_count: Option, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct Bucket { + pub id: String, + pub name: String, + pub revision: u64, + pub rule: Option, + /// Total matching assets, supplied with the bucket list before it is opened. + /// Older providers may omit the count; unknown is distinct from empty. + #[serde(default)] + pub asset_count: Option, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct Rule { + pub scope: Scope, + pub text: String, + pub filters: Filters, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct Asset { + #[serde(default)] + pub faces: Vec, + #[serde(default)] + pub moderation: Option, + pub id: String, + pub folder_id: Option, + pub name: String, + pub mime_type: String, + pub revision: u64, + pub size: u64, + pub edited: bool, + pub tags: Vec, + pub rating: u8, + pub captured_at: Option, + pub modified_at: u64, + pub thumbnail_url: Option, + /// Nearest city from EXIF, absent on older providers or photos without a location. + #[serde(default)] + pub place_name: Option, + /// The EXIF fix itself, for the world map; absent on older providers. + #[serde(default)] + pub location: Option, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct Location { + pub latitude: f64, + pub longitude: f64, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Default)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Scope { + #[default] + Library, + Folder { + id: String, + recursive: bool, + }, + Bucket { + id: String, + }, +} +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +pub struct Filters { + #[serde(skip_serializing_if = "Option::is_none")] + pub person_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub mime_types: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub edited: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub content: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub captured_after: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub captured_before: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub min_rating: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub bounds: Option, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct Bounds { + pub south: f64, + pub north: f64, + pub west: f64, + pub east: f64, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct AssetQuery { + pub scope: Scope, + pub text: String, + pub filters: Filters, + pub sort: String, + pub offset: u64, + pub limit: u64, +} +impl Default for AssetQuery { + fn default() -> Self { + Self { + scope: Scope::Library, + text: String::new(), + filters: Filters::default(), + sort: "name".into(), + offset: 0, + limit: 100, + } + } +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct CatalogueQuery { + pub text: String, + pub offset: u64, + pub limit: u64, +} +impl Default for CatalogueQuery { + fn default() -> Self { + Self { + text: String::new(), + offset: 0, + limit: 500, + } + } +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum WatchQuery { + Folders { query: CatalogueQuery }, + Buckets { query: CatalogueQuery }, + Assets { query: Box }, +} +#[derive(Clone, Debug, Deserialize)] +pub struct Snapshot { + #[serde(default)] + pub screening: Option, + #[serde(default)] + pub people: Option, + pub kind: String, + pub revision: u64, + pub total: u64, + /// Whole-library asset total, independent of this query's filters and page. + #[serde(default)] + pub library_asset_count: Option, + pub offset: u64, + pub items: Vec, +} +#[derive(Clone, Debug, Deserialize)] +pub struct Failure { + pub code: String, + pub message: String, +} +#[derive(Clone, Debug, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ServerMessage { + Ready { + protocol: u32, + }, + Result { + id: String, + value: Value, + }, + Error { + id: String, + error: Failure, + }, + Snapshot { + subscription_id: String, + snapshot: Snapshot, + }, + WatchError { + subscription_id: String, + error: Failure, + }, + DocumentUpdate { + document_id: String, + #[serde(with = "serde_bytes")] + update: Vec, + }, + DocumentError { + document_id: String, + error: Failure, + }, + AuthExpiring, + Pong, +} + +pub fn value(v: T) -> Value { + rmp_serde::from_slice(&rmp_serde::to_vec_named(&v).expect("serializable protocol value")) + .expect("valid MessagePack value") +} +pub fn parse Deserialize<'de>>(v: Value) -> Result { + Ok(rmpv::ext::from_value(v)?) +} +pub fn map(fields: impl IntoIterator) -> Value { + Value::Map(fields.into_iter().map(|(k, v)| (k.into(), v)).collect()) +} +pub fn field<'a>(v: &'a Value, name: &str) -> Result<&'a Value> { + v.as_map() + .and_then(|m| { + m.iter() + .find(|(k, _)| k.as_str() == Some(name)) + .map(|(_, v)| v) + }) + .ok_or_else(|| anyhow::anyhow!("missing {name}")) +} +pub fn bytes(v: &Value, name: &str) -> Result> { + match field(v, name)? { + Value::Binary(b) => Ok(b.clone()), + _ => bail!("{name} must be MessagePack binary"), + } +} +pub fn string(v: &Value, name: &str) -> Result { + Ok(field(v, name)? + .as_str() + .ok_or_else(|| anyhow::anyhow!("{name} must be a string"))? + .into()) +} +pub fn encode(v: &Value) -> Result> { + encode_with_limit(v, MAX_FRAME) +} +pub fn encode_with_limit(v: &Value, limit: usize) -> Result> { + let b = rmp_serde::to_vec_named(v)?; + ensure!( + b.len() <= limit.min(MAX_FRAME), + "Message exceeds the {} byte workspace frame limit", + limit.min(MAX_FRAME) + ); + Ok(b) +} +pub fn decode(b: &[u8]) -> Result { + ensure!(b.len() <= MAX_FRAME, "Message exceeds 256 MiB"); + let mut cursor = std::io::Cursor::new(b); + let v = rmpv::decode::read_value_with_max_depth(&mut cursor, 64)?; + ensure!( + cursor.position() as usize == b.len(), + "Trailing MessagePack data" + ); + // serde_bytes also accepts arrays, but the wire contract specifically requires bin. + if field(&v, "type")?.as_str() == Some("document_update") { + bytes(&v, "update")?; + } + parse(v) +} +pub fn parse_date(raw: &str, end: bool) -> Result> { + if raw.trim().is_empty() { + return Ok(None); + } + let date = chrono::NaiveDate::parse_from_str(raw.trim(), "%Y-%m-%d")?; + let time = date + .and_hms_opt( + if end { 23 } else { 0 }, + if end { 59 } else { 0 }, + if end { 59 } else { 0 }, + ) + .unwrap() + .and_utc() + .timestamp(); + ensure!(time >= 0, "Date precedes 1970"); + Ok(Some(time as u64)) +} +pub fn format_date(t: u64) -> String { + chrono::DateTime::from_timestamp(t as i64, 0) + .map(|d| d.format("%Y-%m-%d").to_string()) + .unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn empty_catalogues_carry_library_totals_with_legacy_support() { + for count in [None, Some(0_u64), Some(5000)] { + let mut snapshot = + serde_json::json!({"kind":"folders","revision":1,"total":0,"offset":0,"items":[]}); + if let Some(count) = count { + snapshot["library_asset_count"] = count.into(); + } + let decoded: Snapshot = + rmp_serde::from_slice(&rmp_serde::to_vec_named(&snapshot).unwrap()).unwrap(); + assert_eq!(decoded.library_asset_count, count); + assert_eq!(decoded.total, 0); + } + } + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn asset_place_names_are_optional_for_older_providers() { + let mut asset = serde_json::json!({"id":"a","folder_id":null,"name":"photo.jpg", + "mime_type":"image/jpeg","revision":1,"size":1,"edited":false, + "tags":[],"rating":0,"captured_at":null,"modified_at":1,"thumbnail_url":null}); + for name in [None, Some("New York City")] { + if let Some(name) = name { + asset["place_name"] = name.into(); + } + let decoded: Asset = + rmp_serde::from_slice(&rmp_serde::to_vec_named(&asset).unwrap()).unwrap(); + assert_eq!(decoded.place_name.as_deref(), name); + } + } + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn folder_counts_arrive_with_the_catalogue() { + for count in [None, Some(0_u64), Some(1234)] { + let mut folder = + serde_json::json!({"id":"f","name":"Photos","revision":1,"parent_id":null}); + if let Some(count) = count { + folder["asset_count"] = count.into(); + } + let bytes = rmp_serde::to_vec_named(&folder).unwrap(); + let decoded: Folder = rmp_serde::from_slice(&bytes).unwrap(); + assert_eq!(decoded.asset_count, count); + } + } + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn bucket_counts_arrive_with_the_catalogue() { + for count in [None, Some(0_u64), Some(1234)] { + let mut bucket = serde_json::json!({"id":"b","name":"Summer","revision":1,"rule":null}); + if let Some(count) = count { + bucket["asset_count"] = count.into(); + } + let bytes = rmp_serde::to_vec_named(&bucket).unwrap(); + let decoded: Bucket = rmp_serde::from_slice(&bytes).unwrap(); + assert_eq!(decoded.asset_count, count); + } + } + pub(super) fn capabilities() -> Capabilities { + Capabilities { + document_models: vec![IMAGE_MODEL.into()], + formats: vec![Format { + id: "codec.png".into(), + name: "PNG".into(), + extensions: vec!["png".into()], + can_export: true, + runtime_requirement: None, + }], + max_frame_bytes: MAX_FRAME as u64, + max_document_bytes: 128 * 1024 * 1024, + default_edited_export: "psd".into(), + original_download: true, + } + } + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn capabilities_fallback_only_for_unknown_method() { + assert!( + Capabilities::from_reply(Err("method_not_found: older provider".into())) + .unwrap() + .is_none() + ); + assert!(Capabilities::from_reply(Err("forbidden: permission denied".into())).is_err()); + assert!(Capabilities::from_reply(Err("Cloud disconnected".into())).is_err()); + let caps = Capabilities::from_reply(Ok(value(capabilities()))) + .unwrap() + .unwrap(); + assert!(caps.supports_image_model()); + assert!(caps.supports_export("png")); + assert!(!caps.supports_export("heic")); + } + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn document_limit_is_independent_of_frame_limit() { + let caps = capabilities(); + assert!(caps.check_document(128 * 1024 * 1024).is_ok()); + assert!(caps.check_document(128 * 1024 * 1024 + 1).is_err()); + let message = map([("update", Value::Binary(vec![0; 24]))]); + let size = encode(&message).unwrap().len(); + assert!(size > 24); + assert!(encode_with_limit(&message, size).is_ok()); + assert!(encode_with_limit(&message, size - 1).is_err()); + let mut caps = caps; + caps.max_frame_bytes = u64::MAX; + assert_eq!(caps.frame_limit(), MAX_FRAME); + } + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn binary_round_trip_and_trailing_data() { + let b: Vec = (0..=255).collect(); + let v = map([ + ("type", "document_update".into()), + ("document_id", "asset".into()), + ("update", Value::Binary(b.clone())), + ]); + let encoded = encode(&v).unwrap(); + match decode(&encoded).unwrap() { + ServerMessage::DocumentUpdate { update, .. } => assert_eq!(update, b), + _ => panic!(), + } + let mut trailing = encoded; + trailing.push(0); + assert!(decode(&trailing).is_err()); + let array = map([ + ("type", "document_update".into()), + ("document_id", "asset".into()), + ("update", Value::Array(vec![1.into(), 2.into()])), + ]); + assert!(decode(&encode(&array).unwrap()).is_err()); + } + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn absent_filters_are_not_nil() { + let v = value(Filters::default()); + assert!(v.as_map().unwrap().is_empty()); + } +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +pub struct Screening { + pub pending: u64, + #[serde(default)] + pub blocked: u64, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct Moderation { + pub status: String, + pub revision: u64, +} +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)] +pub struct People { + pub enabled: bool, + pub pending: u64, + #[serde(default)] + pub failed: u64, + pub unnamed: u64, + pub people: Vec, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct Person { + pub id: String, + pub name: String, + pub asset_count: u64, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct FaceRect { + pub x: f32, + pub y: f32, + pub w: f32, + pub h: f32, +} +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] +pub struct Face { + pub id: String, + pub rect: FaceRect, + #[serde(default)] + pub person_id: Option, + #[serde(default)] + pub suggestion: Option, + #[serde(default)] + pub automatic: bool, +} + +#[cfg(test)] +mod people_contract_tests { + use super::*; + #[test] + fn upload_size_checks_match_the_provider_boundary() { + assert!(validate_upload_size(1).is_ok()); + assert!(validate_upload_size(MAX_UPLOAD_BYTES).is_ok()); + assert!(validate_upload_size(0) + .unwrap_err() + .to_string() + .contains("empty")); + assert!(validate_upload_size(MAX_UPLOAD_BYTES + 1) + .unwrap_err() + .to_string() + .contains("5 GiB")); + assert!(validate_upload_size(u64::MAX).is_err()); + } + #[test] + fn screening_and_people_are_optional_for_legacy_providers() { + let old: Snapshot = serde_json::from_str( + r#"{"kind":"assets","revision":0,"total":0,"offset":0,"items":[]}"#, + ) + .unwrap(); + assert!(old.screening.is_none()); + assert!(old.people.is_none()); + let current: Snapshot=serde_json::from_str(r#"{"kind":"assets","revision":1,"total":0,"offset":0,"items":[],"screening":{"pending":2,"blocked":1},"people":{"enabled":true,"pending":1,"unnamed":2,"people":[{"id":"p","name":"Ann","asset_count":3}]}}"#).unwrap(); + assert_eq!(current.screening.unwrap().pending, 2); + assert_eq!(current.people.unwrap().people[0].asset_count, 3); + } +} diff --git a/crates/cloud/src/runtime.rs b/crates/cloud/src/runtime.rs new file mode 100644 index 00000000..f376898b --- /dev/null +++ b/crates/cloud/src/runtime.rs @@ -0,0 +1,28 @@ +use std::{future::Future, time::Duration}; +#[cfg(not(target_arch = "wasm32"))] +pub fn spawn(task: impl Future + Send + 'static) { + std::thread::spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("cloud runtime") + .block_on(task) + }); +} +#[cfg(target_arch = "wasm32")] +pub fn spawn(task: impl Future + 'static) { + wasm_bindgen_futures::spawn_local(task); +} +pub async fn sleep(duration: Duration) { + #[cfg(not(target_arch = "wasm32"))] + tokio::time::sleep(duration).await; + #[cfg(target_arch = "wasm32")] + gloo_timers::future::TimeoutFuture::new(duration.as_millis().min(u32::MAX as u128) as u32) + .await; +} +pub async fn timeout(duration: Duration, task: impl Future) -> anyhow::Result { + tokio::select! { + result = task => Ok(result), + _ = sleep(duration) => anyhow::bail!("Cloud operation timed out"), + } +} diff --git a/crates/cloud/src/socket.rs b/crates/cloud/src/socket.rs new file mode 100644 index 00000000..97186936 --- /dev/null +++ b/crates/cloud/src/socket.rs @@ -0,0 +1,155 @@ +use anyhow::Result; + +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] +pub enum Message { + Binary(Vec), + Text(String), + Ping, + Pong, + Close(u16), +} +#[cfg(not(target_arch = "wasm32"))] +mod native { + use super::*; + use futures::{SinkExt, StreamExt}; + use tokio_tungstenite::{ + connect_async_with_config, + tungstenite::{protocol::WebSocketConfig, Message as Wire}, + MaybeTlsStream, WebSocketStream, + }; + pub struct Socket(WebSocketStream>); + impl Socket { + pub async fn connect(url: &str) -> Result { + let config = WebSocketConfig::default() + .max_message_size(Some(crate::MAX_FRAME)) + .max_frame_size(Some(crate::MAX_FRAME)); + Ok(Self( + connect_async_with_config(url, Some(config), true).await?.0, + )) + } + pub async fn send(&mut self, bytes: Vec) -> Result<()> { + Ok(self.0.send(Wire::Binary(bytes.into())).await?) + } + pub async fn flush(&mut self) -> Result<()> { + Ok(self.0.flush().await?) + } + pub async fn close(&mut self) -> Result<()> { + Ok(self.0.close(None).await?) + } + pub async fn next(&mut self) -> Option> { + self.0.next().await.map(|m| { + Ok(match m? { + Wire::Binary(b) => Message::Binary(b.to_vec()), + Wire::Text(t) => Message::Text(t.to_string()), + Wire::Ping(_) => Message::Ping, + Wire::Pong(_) => Message::Pong, + Wire::Close(c) => Message::Close(c.map_or(1000, |c| c.code.into())), + _ => anyhow::bail!("Invalid WebSocket frame"), + }) + }) + } + } +} +#[cfg(not(target_arch = "wasm32"))] +pub use native::Socket; +#[cfg(target_arch = "wasm32")] +mod browser { + use super::*; + use anyhow::{anyhow, ensure}; + use wasm_bindgen::{closure::Closure, JsCast}; + use web_sys::{CloseEvent, Event, MessageEvent, WebSocket}; + pub struct Socket { + ws: WebSocket, + messages: tokio::sync::mpsc::UnboundedReceiver>, + _open: Closure, + _message: Closure, + _close: Closure, + _error: Closure, + } + impl Drop for Socket { + fn drop(&mut self) { + self.ws.set_onopen(None); + self.ws.set_onmessage(None); + self.ws.set_onclose(None); + self.ws.set_onerror(None); + let _ = self.ws.close(); + } + } + impl Socket { + pub async fn connect(url: &str) -> Result { + crate::auth::secure_url(url, "wss")?; + let ws = WebSocket::new(url).map_err(|_| anyhow!("Could not open cloud socket"))?; + ws.set_binary_type(web_sys::BinaryType::Arraybuffer); + let (tx, messages) = tokio::sync::mpsc::unbounded_channel(); + let (ready, opened) = tokio::sync::oneshot::channel(); + let mut ready = Some(ready); + let open = Closure::new(move |_: Event| { + if let Some(tx) = ready.take() { + let _ = tx.send(()); + } + }); + let events = tx.clone(); + let message = Closure::new(move |event: MessageEvent| { + let data = event.data(); + let result = if data.is_instance_of::() { + let array = js_sys::Uint8Array::new(&data); + if array.length() as usize > crate::MAX_FRAME { + Err(anyhow!("Cloud frame exceeds limit")) + } else { + Ok(Message::Binary(array.to_vec())) + } + } else if let Some(text) = data.as_string() { + Ok(Message::Text(text)) + } else { + Err(anyhow!("Unsupported cloud frame")) + }; + let _ = events.send(result); + }); + let events = tx.clone(); + let close = Closure::new(move |event: CloseEvent| { + let _ = events.send(Ok(Message::Close(event.code()))); + }); + let error = Closure::new(move |_: Event| { + let _ = tx.send(Err(anyhow!("Cloud WebSocket failed"))); + }); + ws.set_onopen(Some(open.as_ref().unchecked_ref())); + ws.set_onmessage(Some(message.as_ref().unchecked_ref())); + ws.set_onclose(Some(close.as_ref().unchecked_ref())); + ws.set_onerror(Some(error.as_ref().unchecked_ref())); + let socket = Self { + ws, + messages, + _open: open, + _message: message, + _close: close, + _error: error, + }; + crate::runtime::timeout(std::time::Duration::from_secs(15), opened).await??; + Ok(socket) + } + pub async fn send(&mut self, bytes: Vec) -> Result<()> { + ensure!( + self.ws.ready_state() == WebSocket::OPEN, + "Cloud socket is closed" + ); + ensure!( + self.ws.buffered_amount() as usize + bytes.len() <= crate::MAX_FRAME, + "Cloud send buffer is full" + ); + self.ws + .send_with_u8_array(&bytes) + .map_err(|_| anyhow!("Cloud send failed")) + } + pub async fn flush(&mut self) -> Result<()> { + Ok(()) + } + pub async fn close(&mut self) -> Result<()> { + self.ws.close().map_err(|_| anyhow!("Cloud close failed")) + } + pub async fn next(&mut self) -> Option> { + self.messages.recv().await + } + } +} +#[cfg(target_arch = "wasm32")] +pub use browser::Socket; diff --git a/crates/cloud/src/transfer.rs b/crates/cloud/src/transfer.rs new file mode 100644 index 00000000..1d6766aa --- /dev/null +++ b/crates/cloud/src/transfer.rs @@ -0,0 +1,382 @@ +//! Revision-bound downloads and safe names for provider-generated representations. +use crate::{ + auth, + protocol::{map, parse, Capabilities, Value}, + Handle, +}; +#[cfg(not(target_arch = "wasm32"))] +use anyhow::bail; +use anyhow::{ensure, Context, Result}; +use serde::Deserialize; + +#[derive(Deserialize)] +struct Ticket { + url: String, + revision: u64, +} +/// A file's SHA-256 as lowercase hex — what the provider keeps per +/// asset and answers `assets.exists` by. +pub fn sha256_hex(bytes: &[u8]) -> String { + use sha2::Digest as _; + sha2::Sha256::digest(bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} +pub struct DownloadedAsset { + pub bytes: Vec, + pub revision: u64, + pub content_type: Option, + pub content_disposition: Option, + pub format: Option, +} +impl DownloadedAsset { + pub fn suggested_name(&self, original: &str) -> String { + if let Some(name) = self + .content_disposition + .as_deref() + .and_then(disposition_filename) + { + return name; + } + let original = safe_filename(original).unwrap_or_else(|| "download".into()); + let mime = self + .content_type + .as_deref() + .unwrap_or("") + .split(';') + .next() + .unwrap_or("") + .trim() + .to_ascii_lowercase(); + let extension = match mime.as_str() { + "image/vnd.adobe.photoshop" | "image/x-photoshop" => { + Some(if self.bytes.get(4..6) == Some(&[0, 2]) { + "psb" + } else { + "psd" + }) + } + "image/png" => Some("png"), + "image/jpeg" => Some("jpg"), + "image/webp" => Some("webp"), + "image/tiff" => Some("tiff"), + "image/heic" => Some("heic"), + "image/heif" => Some("heif"), + _ => self.format.as_deref().filter(|f| *f != "original"), + }; + match extension { + Some(ext) => std::path::Path::new(&original) + .with_extension(ext) + .to_string_lossy() + .into_owned(), + None => original, + } + } +} +pub fn valid_format(format: &str) -> bool { + (1..=20).contains(&format.len()) + && format + .bytes() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()) +} +fn params(id: &str, format: Option<&str>, capabilities: Option<&Capabilities>) -> Result { + let mut fields = vec![("id", id.into())]; + if let Some(format) = format { + ensure!(valid_format(format), "Invalid download format"); + let capabilities = capabilities.context("Provider does not advertise export support")?; + ensure!( + if format == "original" { + capabilities.original_download + } else { + capabilities.supports_export(format) + }, + "Provider does not support the requested download format" + ); + fields.push(("format", format.into())); + } + Ok(map(fields)) +} +impl Handle { + pub async fn download_asset_async( + &self, + id: &str, + format: Option<&str>, + capabilities: Option<&Capabilities>, + ) -> Result { + #[cfg(not(target_arch = "wasm32"))] + { + self.download_asset(id, format, capabilities) + } + #[cfg(target_arch = "wasm32")] + { + let params = params(id, format, capabilities)?; + for attempt in 0..3 { + let ticket: Ticket = + parse(self.request_async("asset.download", params.clone()).await?)?; + match auth::download_response(&ticket.url, 512 * 1024 * 1024).await { + Ok(response) => { + return Ok(DownloadedAsset { + bytes: response.bytes, + revision: ticket.revision, + content_type: response.content_type, + content_disposition: response.content_disposition, + format: format.map(str::to_owned), + }) + } + Err(e) + if e.downcast_ref::() + .is_some_and(|s| s.0 == 409) + && attempt < 2 => {} + Err(e) => return Err(e), + } + } + unreachable!() + } + } + + #[cfg(not(target_arch = "wasm32"))] + pub fn download_asset( + &self, + id: &str, + format: Option<&str>, + capabilities: Option<&Capabilities>, + ) -> Result { + let params = params(id, format, capabilities)?; + download_with(format, || { + parse(self.request("asset.download", params.clone())?) + }) + } +} +#[cfg(not(target_arch = "wasm32"))] +fn download_with( + format: Option<&str>, + mut ticket: impl FnMut() -> Result, +) -> Result { + for attempt in 0..3 { + let ticket = ticket()?; + match auth::download_response(&ticket.url, 512 * 1024 * 1024) { + Ok(response) => { + return Ok(DownloadedAsset { + bytes: response.bytes, + revision: ticket.revision, + content_type: response.content_type, + content_disposition: response.content_disposition, + format: format.map(str::to_owned), + }) + } + Err(error) + if matches!( + error.downcast_ref::(), + Some(ureq::Error::StatusCode(409)) + ) => + { + if attempt == 2 { + bail!("The cloud file kept changing during download; try again"); + } + } + Err(error) => return Err(error), + } + } + unreachable!() +} +fn safe_filename(name: &str) -> Option { + let name = name.rsplit(['/', '\\']).next()?.trim(); + if name.is_empty() || name == "." || name == ".." || name.chars().any(char::is_control) { + return None; + } + Some(name.replace([':', '*', '?', '"', '<', '>', '|'], "_")) +} +fn disposition_filename(header: &str) -> Option { + // Split parameters without treating semicolons inside a quoted filename as separators. + let mut parts = Vec::new(); + let (mut quoted, mut escaped, mut part) = (false, false, String::new()); + for c in header.chars() { + if escaped { + part.push(c); + escaped = false; + continue; + } + match c { + '\\' if quoted => escaped = true, + '"' => quoted = !quoted, + ';' if !quoted => parts.push(std::mem::take(&mut part)), + _ => part.push(c), + } + } + if quoted || escaped { + return None; + } + parts.push(part); + let mut plain = None; + for part in parts.iter().skip(1) { + let Some((key, value)) = part.trim().split_once('=') else { + continue; + }; + if key.trim().eq_ignore_ascii_case("filename*") { + let Some((charset, rest)) = value.trim().split_once('\'') else { + continue; + }; + let Some((_, encoded)) = rest.split_once('\'') else { + continue; + }; + if !charset.eq_ignore_ascii_case("utf-8") { + continue; + } + if let Some(decoded) = percent_decode(encoded).and_then(|s| safe_filename(&s)) { + return Some(decoded); + } + } else if key.trim().eq_ignore_ascii_case("filename") { + plain = safe_filename(value.trim()); + } + } + plain +} +fn percent_decode(value: &str) -> Option { + let mut bytes = Vec::new(); + let mut input = value.bytes(); + while let Some(c) = input.next() { + bytes.push(if c == b'%' { + let high = (input.next()? as char).to_digit(16)?; + let low = (input.next()? as char).to_digit(16)?; + (high * 16 + low) as u8 + } else { + c + }); + } + String::from_utf8(bytes).ok() +} + +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use super::*; + use std::{ + io::{Read, Write}, + net::TcpListener, + }; + + fn http(statuses: Vec) -> (String, std::thread::JoinHandle<()>) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let worker = std::thread::spawn(move || { + for (index, status) in statuses.into_iter().enumerate() { + let (mut socket, _) = listener.accept().unwrap(); + socket + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); + let mut request = Vec::new(); + let mut byte = [0]; + while !request.ends_with(b"\r\n\r\n") { + socket.read_exact(&mut byte).unwrap(); + request.push(byte[0]); + } + let request = String::from_utf8(request).unwrap(); + assert!(request.starts_with(&format!("GET /{} ", index + 1))); + assert!(!request.to_ascii_lowercase().contains("authorization:")); + assert!(!request.to_ascii_lowercase().contains("cookie:")); + let headers = format!("HTTP/1.1 {status} Response\r\nContent-Length: 3\r\nContent-Type: image/vnd.adobe.photoshop\r\nContent-Disposition: attachment; filename*=UTF-8''edited%20image.psd\r\nConnection: close\r\n\r\n"); + socket.write_all(headers.as_bytes()).unwrap(); + socket.write_all(&[0, 128, 255]).unwrap(); + } + }); + (base, worker) + } + #[test] + fn download_refreshes_conflicting_ticket_and_keeps_metadata() { + let (base, worker) = http(vec![409, 200]); + let mut requests = 0; + let result = download_with(None, || { + requests += 1; + Ok(Ticket { + url: format!("{base}/{requests}"), + revision: requests, + }) + }) + .unwrap(); + worker.join().unwrap(); + assert_eq!(requests, 2); + assert_eq!(result.revision, 2); + assert_eq!(result.bytes, [0, 128, 255]); + assert_eq!( + result.content_type.as_deref(), + Some("image/vnd.adobe.photoshop") + ); + assert!(result.content_disposition.is_some()); + assert_eq!(result.suggested_name("original.heic"), "edited image.psd"); + } + #[test] + fn download_retry_is_bounded_and_does_not_retry_other_statuses() { + for (statuses, count) in [(vec![409, 409, 409], 3), (vec![403], 1)] { + let (base, worker) = http(statuses); + let mut requests = 0; + assert!(download_with(None, || { + requests += 1; + Ok(Ticket { + url: format!("{base}/{requests}"), + revision: requests, + }) + }) + .is_err()); + worker.join().unwrap(); + assert_eq!(requests, count); + } + } + #[test] + fn explicit_formats_require_advertised_support_and_default_omits_format() { + let caps: Capabilities = crate::protocol::parse(map([ + ( + "document_models", + Value::Array(vec![crate::IMAGE_MODEL.into()]), + ), + ( + "formats", + Value::Array(vec![map([ + ("id", "codec.heif".into()), + ("name", "HEIC".into()), + ("extensions", Value::Array(vec!["heic".into()])), + ("can_export", false.into()), + ("runtime_requirement", Value::Nil), + ])]), + ), + ("max_frame_bytes", (crate::MAX_FRAME as u64).into()), + ("max_document_bytes", 128u64.into()), + ("default_edited_export", "psd".into()), + ("original_download", true.into()), + ])) + .unwrap(); + assert!(crate::protocol::field(¶ms("asset", None, None).unwrap(), "format").is_err()); + assert!(params("asset", Some("original"), Some(&caps)).is_ok()); + assert!(params("asset", Some("original"), None).is_err()); + assert!(params("asset", Some("heic"), Some(&caps)).is_err()); + for format in ["", ".png", "PNG", "../png", "a_b", "abcdefghijklmnopqrstu"] { + assert!(!valid_format(format)); + } + } + #[test] + fn filenames_use_disposition_then_content_type_without_directory_traversal() { + assert_eq!( + disposition_filename( + "attachment; filename=plain.psd; filename*=UTF-8'en'%E2%98%83.psd" + ) + .as_deref(), + Some("☃.psd") + ); + assert_eq!( + disposition_filename("attachment; filename=\"a;b.psd\"").as_deref(), + Some("a;b.psd") + ); + assert_eq!( + disposition_filename("attachment; filename*=UTF-8''..%2F..%2Fphoto.psd").as_deref(), + Some("photo.psd") + ); + assert!(disposition_filename("attachment; filename*=UTF-8''%00x.psd").is_none()); + let result = DownloadedAsset { + bytes: vec![], + revision: 1, + content_type: Some("image/png; charset=binary".into()), + content_disposition: None, + format: None, + }; + assert_eq!(result.suggested_name("original.heic"), "original.png"); + } +} diff --git a/crates/cloud/src/transport.rs b/crates/cloud/src/transport.rs new file mode 100644 index 00000000..eee33b7e --- /dev/null +++ b/crates/cloud/src/transport.rs @@ -0,0 +1,900 @@ +use crate::{auth, protocol::*}; +use crate::{ + runtime, + socket::{Message, Socket}, +}; +use anyhow::{anyhow, ensure, Result}; +use std::{ + collections::HashMap, + sync::{ + atomic::{AtomicBool, Ordering}, + mpsc, Arc, + }, + time::Duration, +}; +use tokio::sync::mpsc as channel; +use web_time::Instant; + +type Reply = std::result::Result; +#[derive(Clone)] +pub struct Handle { + tx: channel::UnboundedSender, + /// Whether the workspace socket is up right now — what a long + /// transfer waits on before retrying after a dropped connection. + online: Arc, +} +pub struct Client { + pub handle: Handle, + pub events: mpsc::Receiver, +} +pub struct Upload<'a> { + pub name: &'a str, + pub bytes: &'a [u8], + pub mime: &'a str, + pub folder: Option<&'a str>, + pub asset: Option<(&'a str, u64)>, + pub relative: Option<&'a str>, + pub mutation: &'a str, +} +pub enum Event { + Connected, + Disconnected(String), + AccountUnavailable, + Credentials(Account), + Snapshot { + subscription_id: String, + snapshot: Snapshot, + }, + WatchError { + subscription_id: String, + error: String, + }, + Reply { + id: String, + result: Reply, + }, + DocumentUpdate { + asset_id: String, + bytes: Vec, + }, + DocumentError { + asset_id: String, + error: String, + }, +} +struct Pending { + started: Instant, + reply: Option>, +} +enum Command { + FrameLimit(usize), + Watch(String, WatchQuery), + Unwatch(String), + Request { + id: String, + method: String, + params: Value, + reply: Option>, + }, + Stop, +} +impl Client { + pub fn start(account: Account) -> Self { + let (tx, rx) = channel::unbounded_channel(); + let (events, out) = mpsc::channel(); + let online = Arc::new(AtomicBool::new(false)); + let flag = online.clone(); + #[cfg(not(target_arch = "wasm32"))] + std::thread::spawn(move || { + match tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + { + Ok(rt) => rt.block_on(run(account, rx, events, flag)), + Err(e) => { + let _ = events.send(Event::Disconnected(e.to_string())); + } + } + }); + #[cfg(target_arch = "wasm32")] + runtime::spawn(run(account, rx, events, flag)); + Self { + handle: Handle { tx, online }, + events: out, + } + } +} +impl Drop for Client { + fn drop(&mut self) { + let _ = self.handle.tx.send(Command::Stop); + } +} +impl Handle { + /// Whether the workspace socket is connected and ready. + pub fn online(&self) -> bool { + self.online.load(Ordering::Relaxed) + } + /// Wait for the socket to be up again. `false` when the client has + /// been stopped meanwhile, so the caller gives up instead. + pub async fn wait_online(&self) -> bool { + while !self.online() { + if self.tx.is_closed() { + return false; + } + runtime::sleep(Duration::from_millis(500)).await; + } + true + } + pub fn set_frame_limit(&self, limit: usize) { + let _ = self.tx.send(Command::FrameLimit(limit.min(MAX_FRAME))); + } + pub fn watch(&self, id: &str, query: WatchQuery) { + let _ = self.tx.send(Command::Watch(id.into(), query)); + } + pub fn unwatch(&self, id: &str) { + let _ = self.tx.send(Command::Unwatch(id.into())); + } + pub fn call(&self, method: &str, params: Value) -> String { + let id = uuid::Uuid::new_v4().to_string(); + let _ = self.tx.send(Command::Request { + id: id.clone(), + method: method.into(), + params, + reply: None, + }); + id + } + #[cfg(not(target_arch = "wasm32"))] + pub fn request(&self, method: &str, params: Value) -> Result { + futures::executor::block_on(self.request_async(method, params)) + } + pub async fn request_async(&self, method: &str, params: Value) -> Result { + let (tx, rx) = tokio::sync::oneshot::channel(); + self.tx.send(Command::Request { + id: uuid::Uuid::new_v4().to_string(), + method: method.into(), + params, + reply: Some(tx), + })?; + rx.await?.map_err(|e| anyhow!(e)) + } + #[cfg(not(target_arch = "wasm32"))] + pub fn upload(&self, upload: Upload<'_>) -> Result { + futures::executor::block_on(self.upload_async(upload)) + } + pub async fn upload_async(&self, upload: Upload<'_>) -> Result { + if upload.bytes.len() as u64 > MAX_SINGLE_UPLOAD_BYTES { + return self.upload_large_bytes_async(upload).await; + } + let Upload { + name, + bytes, + mime, + folder, + asset, + relative, + mutation, + } = upload; + validate_upload_size(bytes.len() as u64).map_err(|e| anyhow!("{name}: {e}"))?; + if let Some(path) = relative { + ensure!( + !path.contains(['\\', '\0', ':']) + && path + .split('/') + .all(|p| !p.is_empty() && p != "." && p != ".."), + "Invalid relative import path" + ); + } + let mut fields = vec![ + ("name", name.into()), + ("mime_type", mime.into()), + ("size", (bytes.len() as u64).into()), + ("mutation_id", format!("{mutation}:prepare").into()), + ]; + if let Some(folder) = folder { + fields.push(("folder_id", folder.into())); + } + if let Some((id, revision)) = asset { + fields.push(("asset_id", id.into())); + fields.push(("revision", revision.into())); + } + if let Some(path) = relative { + fields.push(("relative_path", path.into())); + } + let ticket = self + .request_async("asset.prepare_upload", map(fields)) + .await?; + auth::upload_async(&string(&ticket, "put_url")?, mime, bytes).await?; + parse( + self.request_async( + "asset.commit_upload", + map([ + ("upload_id", string(&ticket, "upload_id")?.into()), + ("mutation_id", format!("{mutation}:commit").into()), + ]), + ) + .await?, + ) + } +} +fn finish(events: &mpsc::Sender, id: String, p: Pending, result: Reply) { + if let Some(reply) = p.reply { + let _ = reply.send(result); + } else { + let _ = events.send(Event::Reply { id, result }); + } +} +fn offline( + cmd: Command, + watches: &mut HashMap, + events: &mpsc::Sender, +) -> bool { + match cmd { + Command::FrameLimit(_) => {} + Command::Watch(id, q) => { + watches.insert(id, q); + } + Command::Unwatch(id) => { + watches.remove(&id); + } + Command::Request { id, reply, .. } => finish( + events, + id, + Pending { + started: Instant::now(), + reply, + }, + Err("Cloud is disconnected".into()), + ), + Command::Stop => return false, + } + true +} +/// Whether an error is the connection's fault rather than the request's: +/// a socket that dropped or was down, a transfer the network cut off, a +/// gateway that answered for a server that was not there. Such requests +/// are worth repeating once the connection is back; anything else is an +/// answer. +pub fn transient(error: &anyhow::Error) -> bool { + let text = error.to_string(); + text.contains("Cloud is disconnected") + || text.contains("Cloud connection closed") + || text.contains("Cloud disconnected") + || text.contains("Cloud operation timed out") + || text.contains("Cloud request timed out") + || text.contains("channel closed") + || auth::network_error(error) +} +async fn run( + mut account: Account, + mut commands: channel::UnboundedReceiver, + events: mpsc::Sender, + online: Arc, +) { + let mut watches = HashMap::new(); + let mut retry = 0u32; + let mut force_refresh = false; + loop { + while let Ok(cmd) = commands.try_recv() { + if !offline(cmd, &mut watches, &events) { + return; + } + } + let now = web_time::SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs_f64(); + if force_refresh || account.credentials.expires_at <= now + 30.0 { + #[cfg(not(target_arch = "wasm32"))] + let refreshed = { + let copy = account.clone(); + tokio::task::spawn_blocking(move || auth::refresh(©)) + .await + .unwrap_or_else(|e| Err(e.into())) + }; + #[cfg(target_arch = "wasm32")] + let refreshed = auth::refresh(&account).await; + match refreshed { + Ok(credentials) => { + account.credentials = credentials; + force_refresh = false; + let _ = events.send(Event::Credentials(account.clone())); + } + Err(error) => { + if auth::refresh_rejected(&error) { + let _ = events.send(Event::AccountUnavailable); + return; + } + let _ = events.send(Event::Disconnected(format!( + "Token refresh failed: {error}" + ))); + if !backoff(&mut commands, &mut watches, &events, &mut retry).await { + return; + } + continue; + } + } + } + let session = connected( + &account, + &mut commands, + &mut watches, + &events, + &mut retry, + &online, + ) + .await; + online.store(false, Ordering::Relaxed); + match session { + Ok(Outcome::Unavailable) => { + let _ = events.send(Event::AccountUnavailable); + return; + } + Ok(Outcome::Stop) => return, + Ok(Outcome::Refresh) => { + force_refresh = true; + let _ = events.send(Event::Disconnected("Renewing cloud login…".into())); + } + Err(e) => { + let _ = events.send(Event::Disconnected(e.to_string())); + } + } + if !backoff(&mut commands, &mut watches, &events, &mut retry).await { + return; + } + } +} +async fn backoff( + commands: &mut channel::UnboundedReceiver, + watches: &mut HashMap, + events: &mpsc::Sender, + retry: &mut u32, +) -> bool { + let jitter = uuid::Uuid::new_v4().as_bytes()[0] as u64; + let delay = Duration::from_millis((500u64 << (*retry).min(6)).min(30_000) + jitter); + *retry = retry.saturating_add(1); + let wait = runtime::sleep(delay); + tokio::pin!(wait); + loop { + tokio::select! { + _ = &mut wait => return true, + command = commands.recv() => { + let Some(command) = command else { return false }; + if !offline(command, watches, events) { + return false; + } + } + } + } +} +enum Outcome { + Unavailable, + Stop, + Refresh, +} +struct Session<'a> { + frame_limit: usize, + socket: Socket, + watches: &'a mut HashMap, + events: &'a mpsc::Sender, + online: &'a AtomicBool, + pending: HashMap, + revisions: HashMap, + ready: bool, + started: Instant, + received: Instant, + ping: Instant, +} +async fn connected( + account: &Account, + commands: &mut channel::UnboundedReceiver, + watches: &mut HashMap, + events: &mpsc::Sender, + retry: &mut u32, + online: &AtomicBool, +) -> Result { + let url = account + .credentials + .workspace_websocket_url + .as_deref() + .ok_or_else(|| anyhow!("No workspace endpoint"))?; + #[cfg(not(test))] + auth::secure_url(url, "wss")?; + // Unit tests use a loopback peer; production builds always require TLS. + #[cfg(test)] + if !url.starts_with("ws://127.0.0.1:") { + auth::secure_url(url, "wss")?; + } + let socket = runtime::timeout(Duration::from_secs(15), Socket::connect(url)).await??; + let now = Instant::now(); + let mut session = Session { + frame_limit: MAX_FRAME, + socket, + watches, + events, + online, + pending: HashMap::new(), + revisions: HashMap::new(), + ready: false, + started: now, + received: now, + ping: now, + }; + session + .send(map([ + ("type", "hello".into()), + ("protocol", 1.into()), + ( + "access_token", + account.credentials.access_token.clone().into(), + ), + ])) + .await?; + let result = session.run(commands, retry).await; + // No mutation is automatically retried when its acknowledgement is lost. + for (id, pending) in session.pending.drain() { + finish( + events, + id, + pending, + Err("Cloud disconnected; mutation outcome may be unknown".into()), + ); + } + result +} +impl Session<'_> { + async fn send(&mut self, value: Value) -> Result<()> { + let bytes = encode_with_limit(&value, self.frame_limit)?; + runtime::timeout(Duration::from_secs(10), self.socket.send(bytes)).await??; + Ok(()) + } + async fn run( + &mut self, + commands: &mut channel::UnboundedReceiver, + retry: &mut u32, + ) -> Result { + enum Wake { + Command(Option), + Wire(Option>), + Tick, + } + + let mut tick_due = Instant::now(); + loop { + let wake = tokio::select! { + command = commands.recv() => Wake::Command(command), + message = self.socket.next() => Wake::Wire(message), + _ = runtime::sleep(tick_due.saturating_duration_since(Instant::now())) => Wake::Tick, + }; + let outcome = match wake { + Wake::Command(command) => self.command(command).await?, + Wake::Wire(message) => { + self.message(message.ok_or_else(|| anyhow!("Cloud connection closed"))??) + .await? + } + Wake::Tick => { + tick_due = Instant::now() + Duration::from_millis(100); + self.tick().await?; + None + } + }; + if self.ready { + *retry = 0; + } + if let Some(outcome) = outcome { + return Ok(outcome); + } + } + } + async fn command(&mut self, command: Option) -> Result> { + match command { + Some(Command::FrameLimit(limit)) => self.frame_limit = limit, + None | Some(Command::Stop) => { + let _ = self.socket.close().await; + return Ok(Some(Outcome::Stop)); + } + Some(Command::Watch(id, query)) => { + self.watches.insert(id.clone(), query.clone()); + self.revisions.remove(&id); + if self.ready { + self.subscribe(&id, &query).await?; + } + } + Some(Command::Unwatch(id)) => { + self.watches.remove(&id); + self.revisions.remove(&id); + if self.ready { + self.send(map([ + ("type", "unsubscribe".into()), + ("subscription_id", id.into()), + ])) + .await?; + } + } + Some(Command::Request { + id, + method, + params, + reply, + }) => { + let pending = Pending { + started: Instant::now(), + reply, + }; + if !self.ready { + finish(self.events, id, pending, Err("Cloud is connecting".into())); + return Ok(None); + } + let value = map([ + ("type", "request".into()), + ("id", id.clone().into()), + ("method", method.into()), + ("params", params), + ]); + if let Err(error) = encode_with_limit(&value, self.frame_limit) { + finish(self.events, id, pending, Err(error.to_string())); + return Ok(None); + } + self.pending.insert(id, pending); + self.send(value).await?; + } + } + Ok(None) + } + async fn subscribe(&mut self, id: &str, query: &WatchQuery) -> Result<()> { + self.send(map([ + ("type", "subscribe".into()), + ("subscription_id", id.into()), + ("query", value(query)), + ])) + .await + } + async fn message(&mut self, message: Message) -> Result> { + let bytes = match message { + Message::Binary(bytes) => bytes, + Message::Ping => { + self.socket.flush().await?; + return Ok(None); + } + Message::Pong => { + self.received = Instant::now(); + return Ok(None); + } + Message::Close(frame) => { + if frame == 4403 { + return Ok(Some(Outcome::Unavailable)); + } + if frame == 4401 { + return Ok(Some(Outcome::Refresh)); + } + return Err(anyhow!("Cloud connection closed")); + } + _ => return Err(anyhow!("Cloud requires binary MessagePack frames")), + }; + ensure!( + bytes.len() <= self.frame_limit, + "Incoming workspace frame exceeds the negotiated limit" + ); + self.received = Instant::now(); + let message = decode(&bytes)?; + if !self.ready { + ensure!( + matches!(message, ServerMessage::Ready { protocol: 1 }), + "Expected cloud ready protocol 1" + ); + self.ready = true; + for (id, query) in self.watches.clone() { + self.subscribe(&id, &query).await?; + } + self.online.store(true, Ordering::Relaxed); + let _ = self.events.send(Event::Connected); + return Ok(None); + } + match message { + ServerMessage::Ready { .. } => return Err(anyhow!("Duplicate cloud handshake")), + ServerMessage::Result { id, value } => self.reply(id, Ok(value)), + ServerMessage::Error { id, error } => { + self.reply(id, Err(format!("{}: {}", error.code, error.message))) + } + ServerMessage::Snapshot { + subscription_id, + snapshot, + } => self.snapshot(subscription_id, snapshot)?, + ServerMessage::WatchError { + subscription_id, + error, + } => { + self.watches.remove(&subscription_id); + let _ = self.events.send(Event::WatchError { + subscription_id, + error: error.message, + }); + } + ServerMessage::DocumentUpdate { + document_id, + update, + } => { + let _ = self.events.send(Event::DocumentUpdate { + asset_id: document_id, + bytes: update, + }); + } + ServerMessage::DocumentError { document_id, error } => { + let _ = self.events.send(Event::DocumentError { + asset_id: document_id, + error: error.message, + }); + } + ServerMessage::AuthExpiring => return Ok(Some(Outcome::Refresh)), + ServerMessage::Pong => {} + } + Ok(None) + } + fn reply(&mut self, id: String, result: Reply) { + if let Some(pending) = self.pending.remove(&id) { + finish(self.events, id, pending, result); + } + } + fn snapshot(&mut self, id: String, snapshot: Snapshot) -> Result<()> { + let Some(query) = self.watches.get(&id) else { + return Ok(()); + }; + if self + .revisions + .get(&id) + .is_some_and(|rev| *rev >= snapshot.revision) + { + return Ok(()); + } + let expected = match query { + WatchQuery::Folders { .. } => "folders", + WatchQuery::Buckets { .. } => "buckets", + WatchQuery::Assets { .. } => "assets", + }; + ensure!( + snapshot.kind == expected && snapshot.items.len() <= 500, + "Invalid subscription snapshot" + ); + self.revisions.insert(id.clone(), snapshot.revision); + let _ = self.events.send(Event::Snapshot { + subscription_id: id, + snapshot, + }); + Ok(()) + } + async fn tick(&mut self) -> Result<()> { + ensure!( + self.ready || self.started.elapsed() < Duration::from_secs(15), + "Cloud handshake timed out" + ); + ensure!( + self.received.elapsed() < Duration::from_secs(60), + "Cloud heartbeat timed out" + ); + if self.ready && self.ping.elapsed() > Duration::from_secs(20) { + self.send(map([("type", "ping".into())])).await?; + self.ping = Instant::now(); + } + // Generous: a batch commit stores hundreds of files before it + // answers, and a repeat of a still-running commit would store + // them again. Dead connections are the heartbeat's to catch. + let expired: Vec<_> = self + .pending + .iter() + .filter(|(_, p)| p.started.elapsed() > Duration::from_secs(180)) + .map(|(id, _)| id.clone()) + .collect(); + for id in expired { + self.reply( + id, + Err("Cloud request timed out; mutation outcome may be unknown".into()), + ); + } + Ok(()) + } +} +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests { + use super::*; + use std::net::TcpListener; + use tokio_tungstenite::tungstenite::{accept, Message, WebSocket}; + fn read(ws: &mut WebSocket) -> Value { + match ws.read().unwrap() { + Message::Binary(b) => rmp_serde::from_slice(&b).unwrap(), + other => panic!("expected binary, got {other:?}"), + } + } + fn send(ws: &mut WebSocket, v: Value) { + ws.send(Message::Binary(encode(&v).unwrap().into())) + .unwrap(); + } + fn account(url: String) -> Account { + Account { + domain: "https://schist.app".into(), + exchange_url: "https://schist.app/exchange".into(), + credentials: Credentials { + access_token: "test".into(), + refresh_token: "refresh".into(), + expires_at: 9_999_999_999.0, + generation_endpoint_url: "https://schist.app/generate".into(), + logout_url: "https://schist.app/logout".into(), + workspace_websocket_url: Some(url), + }, + } + } + #[test] + fn account_unavailable_is_terminal_and_not_reconnected() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("ws://{}", listener.local_addr().unwrap()); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut ws = accept(stream).unwrap(); + read(&mut ws); + send( + &mut ws, + map([("type", "ready".into()), ("protocol", 1.into())]), + ); + ws.close(Some(tokio_tungstenite::tungstenite::protocol::CloseFrame { + code: 4403.into(), + reason: "Cloud account unavailable".into(), + })) + .unwrap(); + }); + let client = Client::start(account(url)); + assert!(matches!( + client.events.recv_timeout(Duration::from_secs(5)).unwrap(), + Event::Connected + )); + assert!(matches!( + client.events.recv_timeout(Duration::from_secs(5)).unwrap(), + Event::AccountUnavailable + )); + assert!(matches!( + client.events.recv_timeout(Duration::from_secs(1)), + Err(mpsc::RecvTimeoutError::Disconnected) + )); + server.join().unwrap(); + } + #[test] + fn one_socket_multiplexes_and_resubscribes_without_replaying_mutations() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("ws://{}", listener.local_addr().unwrap()); + let server = std::thread::spawn(move || { + for connection in 0..2 { + let (stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut ws = accept(stream).unwrap(); + assert_eq!(string(&read(&mut ws), "type").unwrap(), "hello"); + send( + &mut ws, + map([("type", "ready".into()), ("protocol", 1.into())]), + ); + let mut subscriptions = 0; + while subscriptions < 2 { + let m = read(&mut ws); + assert_eq!(string(&m, "type").unwrap(), "subscribe"); + let id = string(&m, "subscription_id").unwrap(); + let kind = string(field(&m, "query").unwrap(), "kind").unwrap(); + let q = field(field(&m, "query").unwrap(), "query").unwrap(); + assert!(q.as_map().is_some()); + send( + &mut ws, + map([ + ("type", "snapshot".into()), + ("subscription_id", id.into()), + ( + "snapshot", + map([ + ("kind", kind.into()), + ("revision", 1.into()), + ("total", 0.into()), + ("offset", 0.into()), + ("items", Value::Array(vec![])), + ]), + ), + ]), + ); + subscriptions += 1; + } + if connection == 0 { + let m = read(&mut ws); + assert_eq!(string(&m, "method").unwrap(), "bucket.create"); /* Drop before ack: must not replay. */ + } else { + assert!(matches!(ws.read(), Ok(Message::Close(_)))); + } + } + }); + let client = Client::start(account(url)); + client.handle.watch( + "folders", + WatchQuery::Folders { + query: Default::default(), + }, + ); + client.handle.watch( + "buckets", + WatchQuery::Buckets { + query: Default::default(), + }, + ); + let mut snapshots = 0; + while snapshots < 2 { + if matches!( + client.events.recv_timeout(Duration::from_secs(5)).unwrap(), + Event::Snapshot { .. } + ) { + snapshots += 1; + } + } + let mutation = client.handle.call( + "bucket.create", + map([("name", "New".into()), ("mutation_id", "stable".into())]), + ); + let mut failed = false; + while snapshots < 4 { + match client.events.recv_timeout(Duration::from_secs(5)).unwrap() { + Event::Snapshot { .. } => snapshots += 1, + Event::Reply { id, result } if id == mutation => { + assert!(result.is_err()); + failed = true; + } + _ => {} + } + } + assert!(failed); + drop(client); + server.join().unwrap(); + } + #[test] + fn advertised_frame_limit_rejects_oversize_request_without_disconnect() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let url = format!("ws://{}", listener.local_addr().unwrap()); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(5))) + .unwrap(); + let mut ws = accept(stream).unwrap(); + assert_eq!(string(&read(&mut ws), "type").unwrap(), "hello"); + send( + &mut ws, + map([("type", "ready".into()), ("protocol", 1.into())]), + ); + let request = read(&mut ws); + assert_eq!(string(&request, "method").unwrap(), "small"); + send( + &mut ws, + map([ + ("type", "result".into()), + ("id", string(&request, "id").unwrap().into()), + ("value", Value::Nil), + ]), + ); + assert!(matches!(ws.read(), Ok(Message::Close(_)))); + }); + let client = Client::start(account(url)); + assert!(matches!( + client.events.recv_timeout(Duration::from_secs(5)).unwrap(), + Event::Connected + )); + client.handle.set_frame_limit(128); + let large = client + .handle + .call("large", map([("data", Value::Binary(vec![0; 128]))])); + match client.events.recv_timeout(Duration::from_secs(5)).unwrap() { + Event::Reply { id, result } => { + assert_eq!(id, large); + assert!(result.unwrap_err().contains("128 byte")); + } + _ => panic!("Expected local size rejection"), + } + assert_eq!(client.handle.request("small", map([])).unwrap(), Value::Nil); + drop(client); + server.join().unwrap(); + } +} diff --git a/crates/document/Cargo.toml b/crates/document/Cargo.toml new file mode 100644 index 00000000..7fc4d213 --- /dev/null +++ b/crates/document/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "schist-document" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "Headless Schist codecs and the shared Yjs image model for desktop and cloud" + +[lib] +name = "schist_document" + +[[bin]] +name = "schist-document-worker" +path = "src/main.rs" + +[dependencies] +anyhow.workspace = true +serde.workspace = true +serde_json.workspace = true +rmp-serde = "1" +serde_bytes = "0.11" +yrs = "0.24" +uuid = { version = "1", features = ["v4"] } +schist-core.workspace = true +schist-color.workspace = true +schist-codec-psd.workspace = true +schist-codecs-common.workspace = true +schist-plugin-api.workspace = true + +[dev-dependencies] +image.workspace = true + +[dependencies.web-time] +version = "1" + +[target.'cfg(target_arch = "wasm32")'.dependencies] +uuid = { version = "1", features = ["js"] } + +[target.'cfg(target_arch = "wasm32")'.dev-dependencies] +wasm-bindgen-test = "0.3" diff --git a/crates/document/src/lib.rs b/crates/document/src/lib.rs new file mode 100644 index 00000000..6dc62779 --- /dev/null +++ b/crates/document/src/lib.rs @@ -0,0 +1,118 @@ +//! The editor's document model and built-in codecs without GPUI, GPU, or networking. +//! Callers can register additional trusted codecs in the same PluginRegistry. +use anyhow::{ensure, Context, Result}; +use schist_core::Document; +use schist_plugin_api::{ExportOptions, PluginManifest, PluginRegistry}; +use std::path::Path; +mod shared; +pub use shared::SharedDocument; +pub const MODEL: &str = "schist.image.v1"; + +/// The same built-in registrations, in the same order, as the desktop app. +pub fn registry() -> PluginRegistry { + let mut registry = PluginRegistry::new(); + schist_codecs_common::CommonCodecsPlugin.register(&mut registry); + schist_codecs_common::PsdPlugin.register(&mut registry); + registry +} + +#[derive(serde::Serialize)] +pub struct Format { + pub id: &'static str, + pub name: &'static str, + pub extensions: &'static [&'static str], + pub can_export: bool, + pub runtime_requirement: Option<&'static str>, +} +pub fn formats(registry: &PluginRegistry) -> Vec { + registry + .codecs() + .map(|c| Format { + id: c.id(), + name: c.name(), + extensions: c.extensions(), + can_export: c.can_export(), + runtime_requirement: (c.id() == "codec.heif") + .then_some("libheif with a compatible HEVC decoder"), + }) + .collect() +} + +pub fn import(registry: &PluginRegistry, bytes: &[u8], name: &str) -> Result { + let extension = Path::new(name).extension().and_then(|s| s.to_str()); + let codec = registry + .codec_for(bytes, extension) + .context("No Schist codec recognizes this file")?; + let mut document = codec.import(bytes)?; + document.title = name.to_owned(); + document.path = None; + Ok(document) +} + +/// Reconstruct the native model from a Yjs v1 update without inventing a seed. +pub fn materialize(state: &[u8]) -> Result { + let placeholder = Document::new("unseeded", 1, 1, schist_color::Depth::Eight); + let mut shared = SharedDocument::unseeded(&placeholder)?; + shared.apply(state)?; + shared.render() +} + +pub struct Export { + pub bytes: Vec, + pub extension: String, + pub mime_type: &'static str, +} +/// PSD is the default editable interchange format, including imports whose own +/// format is read-only (camera raw and HEIC). Other formats require an explicit +/// choice: flattened exports never silently replace the layered original. +pub fn export(registry: &PluginRegistry, document: &Document, extension: &str) -> Result { + let extension = extension.trim_start_matches('.').to_ascii_lowercase(); + let codec = registry + .codec_for(&[], Some(&extension)) + .context("Unknown export format")?; + ensure!( + codec.can_export(), + "{} is import-only; export an editable PSD instead", + codec.name() + ); + let depth = match document.depth { + schist_color::Depth::Eight => 8, + schist_color::Depth::Sixteen => 16, + schist_color::Depth::ThirtyTwo => 32, + }; + let bytes = codec.export_with( + document, + &ExportOptions { + bit_depth: depth, + dither: false, + ..Default::default() + }, + )?; + // The PSD writer automatically selects PSB for documents exceeding PSD limits. + let extension = if codec.id() == "codec.psd" { + if bytes.get(4..6) == Some(&[0, 2]) { + "psb" + } else { + "psd" + } + } else { + extension.as_str() + } + .to_owned(); + let mime_type = match codec.id() { + "codec.psd" => "image/vnd.adobe.photoshop", + "codec.png" => "image/png", + "codec.jpeg" => "image/jpeg", + "codec.webp" => "image/webp", + "codec.tiff" => "image/tiff", + _ => "application/octet-stream", + }; + Ok(Export { + bytes, + extension, + mime_type, + }) +} + +#[cfg(all(test, target_arch = "wasm32"))] +wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser); diff --git a/crates/document/src/main.rs b/crates/document/src/main.rs new file mode 100644 index 00000000..d8228f3e --- /dev/null +++ b/crates/document/src/main.rs @@ -0,0 +1,88 @@ +//! One bounded request per process. stdin/stdout are binary; diagnostics use stderr. +//! `import ` reads original file bytes and emits a MessagePack map with +//! `state` (Yjs bin). `export ` reads Yjs bytes and emits `bytes` (bin). +//! `validate` reconstructs a Yjs image; `formats` lists built-in codec capabilities. +use anyhow::{bail, ensure, Context, Result}; +use schist_document::{export, formats, import, materialize, registry, SharedDocument, MODEL}; +use serde::Serialize; +use std::io::{Read, Write}; +const MAX_INPUT: u64 = 256 * 1024 * 1024; +#[derive(Serialize)] +struct Reply { + model: &'static str, + #[serde(skip_serializing_if = "Option::is_none")] + state: Option, + #[serde(skip_serializing_if = "Option::is_none")] + bytes: Option, + width: u32, + height: u32, + #[serde(skip_serializing_if = "Option::is_none")] + extension: Option, + #[serde(skip_serializing_if = "Option::is_none")] + mime_type: Option<&'static str>, +} +fn run() -> Result<()> { + let args: Vec = std::env::args().skip(1).collect(); + let registry = registry(); + if args.first().map(String::as_str) == Some("formats") { + std::io::stdout().write_all(&rmp_serde::to_vec_named(&formats(®istry))?)?; + return Ok(()); + } + let mut bytes = Vec::new(); + std::io::stdin() + .take(MAX_INPUT + 1) + .read_to_end(&mut bytes)?; + ensure!( + bytes.len() as u64 <= MAX_INPUT, + "Worker input exceeds 256 MiB" + ); + let operation = args + .first() + .context("Expected import, export, validate, or formats")?; + let document = if operation == "import" { + import( + ®istry, + &bytes, + args.get(1).context("Import needs a file name")?, + )? + } else { + materialize(&bytes)? + }; + let mut reply = Reply { + model: MODEL, + state: None, + bytes: None, + width: document.width, + height: document.height, + extension: None, + mime_type: None, + }; + match operation.as_str() { + "import" => reply.state = Some(SharedDocument::new(&document)?.full_state().into()), + "export" => { + let result = export( + ®istry, + &document, + args.get(1).map(String::as_str).unwrap_or("psd"), + )?; + reply.bytes = Some(result.bytes.into()); + reply.extension = Some(result.extension); + reply.mime_type = Some(result.mime_type); + } + "validate" => {} + _ => bail!("Unknown operation"), + } + let output = rmp_serde::to_vec_named(&reply)?; + ensure!( + output.len() as u64 <= MAX_INPUT + 1024, + "Worker output exceeds 256 MiB" + ); + std::io::stdout().write_all(&output)?; + Ok(()) +} +fn main() { + if let Err(error) = run() { + eprintln!("{error:#}"); + std::process::exit(1); + } +} diff --git a/crates/document/src/shared.rs b/crates/document/src/shared.rs new file mode 100644 index 00000000..f72a2528 --- /dev/null +++ b/crates/document/src/shared.rs @@ -0,0 +1,681 @@ +//! Yjs-compatible shared image model. Each property and raster/mask tile is a +//! separate CRDT key; editing one layer never overwrites a peer's other layer. +use anyhow::{anyhow, bail, ensure, Result}; +use schist_color::Depth; +use schist_core::BlendMode; +use schist_core::{Document, Layer, LayerId, LayerKind, TileBuf, TileCoord, TileMap, TILE_PIXELS}; +use std::{ + collections::{BTreeMap, HashMap, HashSet}, + sync::Arc, +}; +use yrs::{ + updates::{decoder::Decode, encoder::Encode}, + Any, Doc, Map, Out, ReadTxn, StateVector, Transact, Update, +}; + +type Fields = BTreeMap>; +#[derive(serde::Serialize, serde::Deserialize)] +struct Recovery { + state: serde_bytes::ByteBuf, + bootstrap: Option>, + deferred: BTreeMap>, +} +pub struct SharedDocument { + doc: Doc, + previous: Fields, + ids: HashMap, + local_ids: HashMap, + pub revision: u64, + bootstrap: Option, + deferred: BTreeMap>>, + undo: yrs::UndoManager, +} +impl SharedDocument { + pub fn new(source: &Document) -> Result { + let mut s = Self::unseeded(source)?; + s.seed_if_empty()?; + Ok(s) + } + pub fn unseeded(source: &Document) -> Result { + let doc = Doc::new(); + let root = doc.get_or_insert_map("schist.image.v1"); + let mut undo = yrs::UndoManager::with_scope_and_options( + &doc, + &root, + yrs::undo::Options { + capture_timeout_millis: 500, + tracked_origins: Default::default(), + capture_transaction: None, + timestamp: std::sync::Arc::new(|| { + web_time::SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 + }), + }, + ); + undo.include_origin("local"); + let mut s = Self { + doc, + undo, + bootstrap: Some(Fields::new()), + deferred: BTreeMap::new(), + previous: Fields::new(), + ids: HashMap::new(), + local_ids: HashMap::new(), + revision: source.revision, + }; + fn seed( + layers: &[Layer], + prefix: &str, + ids: &mut HashMap, + local: &mut HashMap, + ) { + for (i, l) in layers.iter().enumerate() { + let id = format!("{prefix}/{i}"); + ids.insert(l.id, id.clone()); + local.insert(id.clone(), l.id); + if let Some(children) = l.children() { + seed(children, &id, ids, local); + } + } + } + seed(&source.tree.layers, "seed", &mut s.ids, &mut s.local_ids); + s.previous = s.capture(source)?; + s.bootstrap = Some(s.previous.clone()); + Ok(s) + } + pub fn seed_if_empty(&mut self) -> Result<()> { + let root = self.doc.get_or_insert_map("schist.image.v1"); + if let Some(initial) = self.bootstrap.take() { + if root.len(&self.doc.transact()) == 0 { + // A join to an existing room wins over the downloaded export. + // Peers initializing the same original emit an identical seed. + let seed = Doc::with_client_id(1); + let map = seed.get_or_insert_map("schist.image.v1"); + { + let mut txn = seed.transact_mut(); + for (k, v) in initial { + map.insert(&mut txn, k, Any::Buffer(Arc::from(v))); + } + } + let update = seed + .transact() + .encode_state_as_update_v1(&StateVector::default()); + self.apply(&update)?; + } + // Edits during join are deltas, not a replacement of the remote snapshot. + let mut txn = self.doc.transact_mut_with("local"); + for (k, v) in std::mem::take(&mut self.deferred) { + match v { + Some(v) => { + root.insert(&mut txn, k, Any::Buffer(Arc::from(v))); + } + None => { + root.remove(&mut txn, &k); + } + } + } + } + Ok(()) + } + pub fn undo(&mut self, redo: bool) -> bool { + if redo { + self.undo.redo_blocking() + } else { + self.undo.undo_blocking() + } + } + pub fn state_vector(&self) -> Vec { + self.doc.transact().state_vector().encode_v1() + } + pub fn diff(&self, vector: &[u8]) -> Result> { + let sv = StateVector::decode_v1(vector)?; + Ok(self.doc.transact().encode_state_as_update_v1(&sv)) + } + pub fn full_state(&self) -> Vec { + self.doc + .transact() + .encode_state_as_update_v1(&StateVector::default()) + } + pub fn checkpoint(&self) -> Result> { + Ok(rmp_serde::to_vec_named(&Recovery { + state: self.full_state().into(), + bootstrap: self.bootstrap.as_ref().map(|fields| { + fields + .iter() + .map(|(k, v)| (k.clone(), v.clone().into())) + .collect() + }), + deferred: self + .deferred + .iter() + .map(|(k, v)| (k.clone(), v.clone().map(Into::into))) + .collect(), + })?) + } + pub fn restore(&mut self, bytes: &[u8], source: &Document) -> Result { + let saved: Recovery = rmp_serde::from_slice(bytes)?; + self.apply(&saved.state)?; + self.bootstrap = saved + .bootstrap + .map(|fields| fields.into_iter().map(|(k, v)| (k, v.into_vec())).collect()); + self.deferred = saved + .deferred + .into_iter() + .map(|(k, v)| (k, v.map(|b| b.into_vec()))) + .collect(); + // Display recovered edits immediately, retaining their unjoined deltas for sync. + let mut preview = Self::unseeded(source)?; + preview.apply(&self.full_state())?; + preview.bootstrap = self.bootstrap.clone(); + preview.deferred = self.deferred.clone(); + preview.seed_if_empty()?; + let document = preview.render()?; + self.ids = preview.ids; + self.local_ids = preview.local_ids; + self.previous = preview.previous; + self.revision = document.revision; + Ok(document) + } + pub fn apply(&mut self, bytes: &[u8]) -> Result<()> { + self.doc + .transact_mut_with("remote") + .apply_update(Update::decode_v1(bytes)?)?; + Ok(()) + } + /// Capture only local deltas against the last rendered state. Concurrent remote + /// changes already present in the CRDT are never rewritten from a stale UI snapshot. + pub fn local_changes(&mut self, source: &Document) -> Result>> { + let next = self.capture(source)?; + let sv = self.doc.transact().state_vector(); + let map = self.doc.get_or_insert_map("schist.image.v1"); + let mut changed = false; + { + let mut txn = self.doc.transact_mut_with("local"); + for (k, v) in &next { + if self.previous.get(k) != Some(v) { + if self.bootstrap.is_some() { + self.deferred.insert(k.clone(), Some(v.clone())); + } else { + map.insert(&mut txn, k.as_str(), Any::Buffer(Arc::from(v.clone()))); + } + changed = true; + } + } + for k in self.previous.keys() { + if !next.contains_key(k) { + if self.bootstrap.is_some() { + self.deferred.insert(k.clone(), None); + } else { + map.remove(&mut txn, k); + } + changed = true; + } + } + } + self.previous = next; + self.revision = source.revision; + Ok(changed.then(|| self.doc.transact().encode_state_as_update_v1(&sv))) + } + fn capture(&mut self, source: &Document) -> Result { + let mut out = Fields::new(); + out.insert( + "document/size".into(), + rmp_serde::to_vec(&(source.width, source.height, source.resolution_dpi))?, + ); + out.insert("document/title".into(), source.title.as_bytes().to_vec()); + let mut meta = Document::new("metadata", 1, 1, source.depth); + meta.mode = source.mode; + meta.icc_profile = source.icc_profile.clone(); + meta.preserved_resources = source.preserved_resources.clone(); + meta.global_layer_mask = source.global_layer_mask.clone(); + meta.preserved_layer_info = source.preserved_layer_info.clone(); + meta.guides = source.guides.clone(); + meta.artboards = source.artboards.clone(); + meta.slices = source.slices.clone(); + meta.notes = source.notes.clone(); + meta.counts = source.counts.clone(); + meta.paths = source.paths.clone(); + out.insert( + "document/metadata".into(), + schist_codec_psd::write_psd(&meta)?, + ); + self.capture_layers(&source.tree.layers, "root", source.depth, &mut out)?; + // Comp references use stable shared IDs, never another process's LayerId values. + let mut references = Vec::new(); + let mut comps = source.layer_comps.clone(); + for comp in &mut comps { + comp.states.retain_mut(|state| { + let Some(id) = self.ids.get(&state.layer) else { + return false; + }; + state.layer = LayerId(references.len() as u64); + references.push(id.clone()); + true + }); + } + out.insert( + "document/comps".into(), + rmp_serde::to_vec(&(references, comps))?, + ); + Ok(out) + } + fn capture_layers( + &mut self, + layers: &[Layer], + parent: &str, + depth: Depth, + out: &mut Fields, + ) -> Result<()> { + for (i, layer) in layers.iter().enumerate() { + let id = self + .ids + .entry(layer.id) + .or_insert_with(|| uuid::Uuid::new_v4().to_string()) + .clone(); + self.local_ids.insert(id.clone(), layer.id); + let prefix = format!("layer/{id}"); + // Parent + sibling rank is one atomic placement. Tie-break concurrent insertions + // by stable UUID; no layer is lost if two users insert at the same position. + out.insert( + format!("{prefix}/placement"), + rmp_serde::to_vec(&(parent, i as u64))?, + ); + for (name, data) in [ + ("name", layer.name.as_bytes().to_vec()), + ("visible", vec![layer.visible as u8]), + ("opacity", layer.opacity.to_le_bytes().to_vec()), + ("fill", layer.fill_opacity.to_le_bytes().to_vec()), + ("blend", layer.blend.psd_key().to_vec()), + ("clipping", vec![layer.clipping as u8]), + ("locked", vec![layer.locked as u8]), + ] { + out.insert(format!("{prefix}/{name}"), data); + } + let mut template = layer.clone(); + template.id = LayerId(1); + template.name = "layer".into(); + template.visible = true; + template.opacity = 1.0; + template.fill_opacity = 1.0; + template.blend = BlendMode::Normal; + template.clipping = false; + template.locked = false; + template.styled = None; + if let Some(r) = template.as_raster_mut() { + r.tiles = TileMap::new(); + } + if let Some(mask) = &mut template.mask { + mask.tiles = Default::default(); + } + if let LayerKind::Group(g) = &mut template.kind { + g.children.clear(); + } + let mut doc = Document::new("layer", 1, 1, depth); + doc.tree.layers.push(template); + out.insert( + format!("{prefix}/template"), + schist_codec_psd::write_psd(&doc)?, + ); + if let Some(r) = layer.as_raster() { + for (c, tile) in r.tiles.iter() { + out.insert( + format!("{prefix}/pixels/{}/{}", c.tx, c.ty), + tile_bytes(tile), + ); + } + } + if let Some(mask) = &layer.mask { + for (c, tile) in mask.tiles.iter() { + out.insert( + format!("{prefix}/mask/{}/{}", c.tx, c.ty), + tile.as_slice().to_vec(), + ); + } + } + if let Some(children) = layer.children() { + self.capture_layers(children, &id, depth, out)?; + } + } + Ok(()) + } + pub fn render(&mut self) -> Result { + let map = self.doc.get_or_insert_map("schist.image.v1"); + let txn = self.doc.transact(); + let fields: Fields = map + .iter(&txn) + .map(|(k, v)| match v { + Out::Any(Any::Buffer(b)) => Ok((k.to_string(), b.to_vec())), + _ => Err(anyhow!("Invalid image field")), + }) + .collect::>()?; + drop(txn); + let get = |key: &str| { + fields + .get(key) + .ok_or_else(|| anyhow!("Incomplete shared image: {key}")) + }; + let mut doc = schist_codec_psd::read_psd(get("document/metadata")?)?; + let (w, h, dpi): (u32, u32, f32) = rmp_serde::from_slice(get("document/size")?)?; + ensure!( + w > 0 && h > 0 && w <= 300000 && h <= 300000, + "Invalid shared canvas size" + ); + doc.width = w; + doc.height = h; + doc.resolution_dpi = dpi; + doc.title = String::from_utf8(get("document/title")?.clone())?; + let mut layers = HashMap::new(); + let mut places: BTreeMap> = BTreeMap::new(); + for (key, data) in &fields { + let Some(id) = key + .strip_prefix("layer/") + .and_then(|k| k.strip_suffix("/template")) + else { + continue; + }; + let p = format!("layer/{id}"); + // Deleting a layer removes its placement. Ignore concurrent writes to its old fields. + let Some(placement) = fields.get(&format!("{p}/placement")) else { + continue; + }; + let (parent, rank): (String, u64) = rmp_serde::from_slice(placement)?; + let mut parsed = schist_codec_psd::read_psd(data)?; + ensure!(parsed.tree.layers.len() == 1, "Invalid layer template"); + let mut layer = parsed.tree.layers.remove(0); + layer.id = *self + .local_ids + .entry(id.into()) + .or_insert_with(LayerId::next); + self.ids.insert(layer.id, id.into()); + layer.name = String::from_utf8(get(&format!("{p}/name"))?.clone())?; + layer.visible = get(&format!("{p}/visible"))?.first() == Some(&1); + layer.locked = get(&format!("{p}/locked"))?.first() == Some(&1); + layer.clipping = get(&format!("{p}/clipping"))?.first() == Some(&1); + let float = |field: &str| -> Result { + let b = get(&format!("{p}/{field}"))?; + let f = f32::from_le_bytes(b.as_slice().try_into()?); + ensure!(f.is_finite(), "Invalid opacity"); + Ok(f.clamp(0.0, 1.0)) + }; + layer.opacity = float("opacity")?; + layer.fill_opacity = float("fill")?; + let blend: [u8; 4] = get(&format!("{p}/blend"))?.as_slice().try_into()?; + layer.blend = + BlendMode::from_psd_key(&blend).ok_or_else(|| anyhow!("Invalid blend mode"))?; + if let Some(r) = layer.as_raster_mut() { + r.tiles = TileMap::new(); + } + for (k, b) in fields.range(format!("{p}/")..) { + if !k.starts_with(&format!("{p}/")) { + break; + } + for channel in ["pixels", "mask"] { + if let Some(pos) = k.strip_prefix(&format!("{p}/{channel}/")) { + let (x, y) = pos + .split_once('/') + .ok_or_else(|| anyhow!("Invalid tile coordinate"))?; + let c = TileCoord { + tx: x.parse()?, + ty: y.parse()?, + }; + ensure!( + c.tx.unsigned_abs() < 1_000_000 && c.ty.unsigned_abs() < 1_000_000, + "Tile coordinate out of bounds" + ); + if channel == "pixels" { + if let Some(r) = layer.as_raster_mut() { + r.tiles.insert(c, Arc::new(read_tile(b)?)); + } + } else if let Some(mask) = &mut layer.mask { + let a: [u8; TILE_PIXELS] = b.as_slice().try_into()?; + mask.tiles.insert(c, Arc::new(a)); + } + } + } + } + places.entry(parent).or_default().push((rank, id.into())); + layers.insert(id.to_string(), layer); + } + fn build( + parent: &str, + places: &mut BTreeMap>, + layers: &mut HashMap, + seen: &mut HashSet, + depth: usize, + ) -> Result> { + ensure!(depth < 128, "Shared layer tree too deep"); + let mut siblings = places.remove(parent).unwrap_or_default(); + siblings.sort(); + let mut out = Vec::new(); + for (_, id) in siblings { + if !seen.insert(id.clone()) { + continue; + } + if let Some(mut l) = layers.remove(&id) { + if let LayerKind::Group(g) = &mut l.kind { + g.children = build(&id, places, layers, seen, depth + 1)?; + } + out.push(l); + } + } + Ok(out) + } + doc.tree.layers = build("root", &mut places, &mut layers, &mut HashSet::new(), 0)?; + // Concurrent moves can leave an orphan/cycle. Keep those layers visible at root. + let mut orphan: Vec<_> = layers.into_iter().collect(); + orphan.sort_by(|a, b| a.0.cmp(&b.0)); + doc.tree.layers.extend(orphan.into_iter().map(|(_, l)| l)); + if let Some(data) = fields.get("document/comps") { + let (references, mut comps): (Vec, Vec) = + rmp_serde::from_slice(data)?; + for comp in &mut comps { + comp.states.retain_mut(|state| { + let Some(id) = references + .get(state.layer.0 as usize) + .and_then(|id| self.local_ids.get(id)) + else { + return false; + }; + state.layer = *id; + doc.tree.find(*id).is_some() + }); + } + doc.layer_comps = comps; + } + doc.active_layer = doc.tree.layers.last().map(|l| l.id); + doc.mark_dirty(); + self.previous = fields; + self.revision = doc.revision; + Ok(doc) + } +} +fn tile_bytes(tile: &TileBuf) -> Vec { + let mut out = Vec::with_capacity(tile.byte_len() + 1); + match tile { + TileBuf::U8(b) => { + out.push(8); + out.extend_from_slice(b); + } + TileBuf::U16(b) => { + out.push(16); + for v in b.iter() { + out.extend_from_slice(&v.to_le_bytes()); + } + } + TileBuf::F32(b) => { + out.push(32); + for v in b.iter() { + out.extend_from_slice(&v.to_le_bytes()); + } + } + } + out +} +fn read_tile(b: &[u8]) -> Result { + ensure!(!b.is_empty(), "Empty tile"); + let n = TILE_PIXELS * 4; + Ok(match b[0] { + 8 => { + ensure!(b.len() == 1 + n, "Invalid u8 tile"); + TileBuf::U8(b[1..].to_vec().into_boxed_slice()) + } + 16 => { + ensure!(b.len() == 1 + n * 2, "Invalid u16 tile"); + TileBuf::U16( + b[1..] + .as_chunks::<2>() + .0 + .iter() + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(), + ) + } + 32 => { + ensure!(b.len() == 1 + n * 4, "Invalid f32 tile"); + TileBuf::F32( + b[1..] + .as_chunks::<4>() + .0 + .iter() + .map(|c| f32::from_le_bytes(*c)) + .collect(), + ) + } + _ => bail!("Unknown tile depth"), + }) +} +#[cfg(test)] +mod tests { + use super::*; + fn sample() -> Document { + let mut d = Document::new("Photo", 256, 256, Depth::Eight); + d.tree.layers.push(Layer::new_raster("A")); + d.tree.layers.push(Layer::new_raster("B")); + d + } + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn concurrent_properties_and_tiles_merge() { + let mut da = sample(); + let mut a = SharedDocument::new(&da).unwrap(); + let mut b = SharedDocument::new(&da).unwrap(); + let mut db = b.render().unwrap(); + da.tree.layers[0].name = "Alice".into(); + let ua = a.local_changes(&da).unwrap().unwrap(); + db.tree.layers[0].opacity = 0.5; + db.tree.layers[1] + .as_raster_mut() + .unwrap() + .tiles + .get_mut_or_insert(TileCoord { tx: 0, ty: 0 }, Depth::Eight) + .set(0, schist_color::Rgba::new(1.0, 0.0, 0.0, 1.0)); + let ub = b.local_changes(&db).unwrap().unwrap(); + a.apply(&ub).unwrap(); + b.apply(&ua).unwrap(); + for s in [&mut a, &mut b] { + let d = s.render().unwrap(); + assert_eq!(d.tree.layers[0].name, "Alice"); + assert_eq!(d.tree.layers[0].opacity, 0.5); + assert_eq!( + d.tree.layers[1].as_raster().unwrap().tiles.pixel(0, 0).r, + 1.0 + ); + } + } + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn native_tile_depths_roundtrip() { + for depth in [Depth::Eight, Depth::Sixteen, Depth::ThirtyTwo] { + let mut t = TileBuf::new(depth); + t.set(5, schist_color::Rgba::new(0.2, 0.6, 0.1, 1.0)); + assert_eq!(t, read_tile(&tile_bytes(&t)).unwrap()); + } + } + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn edits_during_join_merge_with_existing_room() { + let mut original = sample(); + let mut server = SharedDocument::new(&original).unwrap(); + original.tree.layers[0].name = "Remote".into(); + server.local_changes(&original).unwrap(); + let mut local = sample(); + let mut client = SharedDocument::unseeded(&local).unwrap(); + local.tree.layers[1].opacity = 0.4; + client.local_changes(&local).unwrap(); + client.apply(&server.full_state()).unwrap(); + client.seed_if_empty().unwrap(); + let result = client.render().unwrap(); + assert_eq!(result.tree.layers[0].name, "Remote"); + assert_eq!(result.tree.layers[1].opacity, 0.4); + } + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn undo_only_removes_local_changes() { + let mut local = sample(); + let mut a = SharedDocument::new(&local).unwrap(); + let mut b = SharedDocument::new(&local).unwrap(); + local.tree.layers[0].name = "Local".into(); + let update = a.local_changes(&local).unwrap().unwrap(); + b.apply(&update).unwrap(); + let mut other = b.render().unwrap(); + other.tree.layers[1].name = "Remote".into(); + let update = b.local_changes(&other).unwrap().unwrap(); + a.apply(&update).unwrap(); + assert!(a.undo(false)); + let result = a.render().unwrap(); + assert_eq!(result.tree.layers[0].name, "A"); + assert_eq!(result.tree.layers[1].name, "Remote"); + } + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn recovery_keeps_unjoined_edits_without_overwriting_peers() { + let mut local = sample(); + let mut client = SharedDocument::unseeded(&local).unwrap(); + local.tree.layers[1].opacity = 0.3; + client.local_changes(&local).unwrap(); + let checkpoint = client.checkpoint().unwrap(); + let original = sample(); + let mut restored = SharedDocument::unseeded(&original).unwrap(); + let preview = restored.restore(&checkpoint, &original).unwrap(); + assert_eq!(preview.tree.layers[1].opacity, 0.3); + let mut server = SharedDocument::new(&original).unwrap(); + let mut peer = server.render().unwrap(); + peer.tree.layers[0].name = "Peer".into(); + server.local_changes(&peer).unwrap(); + restored.apply(&server.full_state()).unwrap(); + restored.seed_if_empty().unwrap(); + let result = restored.render().unwrap(); + assert_eq!(result.tree.layers[0].name, "Peer"); + assert_eq!(result.tree.layers[1].opacity, 0.3); + } + #[cfg_attr(not(target_arch = "wasm32"), test)] + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + fn layer_comp_references_survive_other_process_ids() { + let mut local = sample(); + let mut comp = schist_core::LayerComp::new("Alternative"); + comp.states.push(schist_core::LayerCompState { + layer: local.tree.layers[1].id, + visible: false, + opacity: 0.4, + fill_opacity: 0.8, + blend: BlendMode::Normal, + style: Default::default(), + }); + local.layer_comps.push(comp); + let source = SharedDocument::new(&local).unwrap(); + let other = sample(); + assert_ne!(local.tree.layers[1].id, other.tree.layers[1].id); + let mut peer = SharedDocument::unseeded(&other).unwrap(); + peer.apply(&source.full_state()).unwrap(); + peer.seed_if_empty().unwrap(); + let result = peer.render().unwrap(); + assert_eq!( + result.layer_comps[0].states[0].layer, + result.tree.layers[1].id + ); + assert_eq!(result.layer_comps[0].states[0].opacity, 0.4); + assert!(peer.local_changes(&result).unwrap().is_none()); + } +} diff --git a/crates/document/tests/roundtrip.rs b/crates/document/tests/roundtrip.rs new file mode 100644 index 00000000..851ca4cd --- /dev/null +++ b/crates/document/tests/roundtrip.rs @@ -0,0 +1,143 @@ +use schist_document::{export, import, materialize, registry, SharedDocument}; +use schist_plugin_api::PluginManifest; +use std::path::Path; +fn check(bytes: &[u8], name: &str) { + let codecs = registry(); + let original = import(&codecs, bytes, name).unwrap(); + let seed = SharedDocument::new(&original).unwrap().full_state(); + let independent = import(&codecs, bytes, name).unwrap(); + assert_eq!( + seed, + SharedDocument::new(&independent).unwrap().full_state(), + "bootstrap must be deterministic: {name}" + ); + let restored = materialize(&seed).unwrap(); + assert_eq!( + (original.width, original.height, original.depth), + (restored.width, restored.height, restored.depth) + ); + assert_eq!(original.tree.layers.len(), restored.tree.layers.len()); + for (a, b) in original.tree.layers.iter().zip(&restored.tree.layers) { + assert_eq!(a.name, b.name); + assert_eq!(a.opacity, b.opacity); + assert_eq!(a.visible, b.visible); + if let (Some(a), Some(b)) = (a.as_raster(), b.as_raster()) { + assert_eq!(a.tiles.pixel(0, 0), b.tiles.pixel(0, 0)); + } + } + let editable = export(&codecs, &restored, "psd").unwrap(); + let reopened = import(&codecs, &editable.bytes, "edited.psd").unwrap(); + assert_eq!( + (reopened.width, reopened.height), + (original.width, original.height) + ); +} +#[test] +fn all_builtin_registrations_match_the_desktop() { + let actual = registry(); + let mut desktop = schist_plugin_api::PluginRegistry::new(); + schist_codecs_common::CommonCodecsPlugin.register(&mut desktop); + schist_codecs_common::PsdPlugin.register(&mut desktop); + assert_eq!( + actual + .codecs() + .map(|c| (c.id(), c.extensions())) + .collect::>(), + desktop + .codecs() + .map(|c| (c.id(), c.extensions())) + .collect::>() + ); +} +#[test] +fn common_raster_formats_share_the_same_model() { + for (format, name) in [ + (image::ImageFormat::Png, "color.png"), + (image::ImageFormat::Jpeg, "color.jpg"), + (image::ImageFormat::WebP, "color.webp"), + (image::ImageFormat::Tiff, "color.tiff"), + ] { + let img = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel( + 8, + 6, + image::Rgb([20, 100, 200]), + )); + let mut bytes = std::io::Cursor::new(Vec::new()); + img.write_to(&mut bytes, format).unwrap(); + check(bytes.get_ref(), name); + } +} +#[test] +fn layered_psd_and_affinity_fixtures_roundtrip() { + let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures"); + for relative in [ + "psd/im_two_layers.psd", + "psd/im_three_layers_rle.psd", + "psd/im_gray_flat.psd", + "affinity/color.afdesign", + "affinity/raster_test.afdesign", + ] { + let path = root.join(relative); + check( + &std::fs::read(&path).unwrap(), + path.file_name().unwrap().to_str().unwrap(), + ); + } +} +#[test] +fn deep_raster_precision_survives_the_shared_model() { + let img = image::DynamicImage::ImageRgba16(image::ImageBuffer::from_pixel( + 8, + 6, + image::Rgba([0x0180u16, 0x8000, 0xffff, 0xffff]), + )); + let mut bytes = std::io::Cursor::new(Vec::new()); + img.write_to(&mut bytes, image::ImageFormat::Png).unwrap(); + check(bytes.get_ref(), "deep.png"); +} +#[test] +fn heic_uses_the_same_optional_decoder_as_desktop() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/heif/rgb.heic"); + let bytes = std::fs::read(path).unwrap(); + match import(®istry(), &bytes, "rgb.heic") { + Err(e) if schist_codecs_common::heif::no_decoder_available(&e) => { + eprintln!("HEIC runtime unavailable: {e}") + } + Err(e) => panic!("{e:#}"), + Ok(_) => check(&bytes, "rgb.heic"), + } +} + +#[test] +fn path_text_and_opentype_metadata_survive_cloud_sync_and_export() { + let image = image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel( + 8, + 8, + image::Rgb([20, 80, 120]), + )); + let mut png = std::io::Cursor::new(Vec::new()); + image.write_to(&mut png, image::ImageFormat::Png).unwrap(); + let codecs = registry(); + let mut document = import(&codecs, png.get_ref(), "text.png").unwrap(); + // PsTx carries editable text JSON, including newly added typography fields. + let payload=br#"{"origin":[4,5],"spec":{"text":"Along a path","features":[{"tag":"liga","value":1}],"path":{"closed":false,"points":[[0,0],[20,5]]}}}"#.to_vec(); + document.tree.layers[0] + .extras + .push(schist_core::layer::RawBlock { + key: *b"PsTx", + data: payload.clone(), + }); + let shared = SharedDocument::new(&document).unwrap(); + let restored = materialize(&shared.full_state()).unwrap(); + let exported = export(&codecs, &restored, "psd").unwrap(); + let reopened = import(&codecs, &exported.bytes, "text.psd").unwrap(); + assert_eq!( + reopened.tree.layers[0] + .extras + .iter() + .find(|b| b.key == *b"PsTx") + .unwrap() + .data, + payload + ); +} diff --git a/crates/people-worker/Cargo.toml b/crates/people-worker/Cargo.toml new file mode 100644 index 00000000..8a88761f --- /dev/null +++ b/crates/people-worker/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "schist-people-worker" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +image.workspace = true +serde.workspace = true +serde_json.workspace = true +schist-neural = { path = "../neural" } +schist-gallery = { path = "../gallery" } diff --git a/crates/people-worker/src/main.rs b/crates/people-worker/src/main.rs new file mode 100644 index 00000000..07dd45af --- /dev/null +++ b/crates/people-worker/src/main.rs @@ -0,0 +1,80 @@ +//! A bounded cloud adapter for desktop's People models. No network or credentials. +use anyhow::{ensure, Context, Result}; +use schist_gallery::FaceRect; +use serde::{Deserialize, Serialize}; +use std::io::{Read, Write}; +#[derive(Deserialize)] +struct Request { + width: u32, + height: u32, + rgb: Vec, + #[serde(default)] + boxes: Option>, +} +#[derive(Serialize)] +struct Face { + rect: FaceRect, + embedding: Vec, +} +fn run() -> Result<()> { + let mut bytes = Vec::new(); + std::io::stdin() + .take(24 * 1024 * 1024 + 1) + .read_to_end(&mut bytes)?; + ensure!(bytes.len() <= 24 * 1024 * 1024, "Input too large"); + let request: Request = serde_json::from_slice(&bytes)?; + ensure!( + (1..=1024).contains(&request.width) && (1..=1024).contains(&request.height), + "Invalid dimensions" + ); + let image = image::RgbImage::from_raw(request.width, request.height, request.rgb) + .context("Invalid pixels")?; + let boxes = if let Some(boxes) = request.boxes { + ensure!(boxes.len() <= 100, "Too many faces"); + boxes.into_iter().map(FaceRect::clamped).collect() + } else { + let detector = schist_neural::get("face").context("Detection model missing")?; + let rgb: Vec = image.as_raw().iter().map(|v| *v as f32 / 255.0).collect(); + schist_neural::faces( + &detector, + &rgb, + request.width as usize, + request.height as usize, + )? + .into_iter() + .take(100) + .map(|face| { + FaceRect::from_pixels( + face.x, + face.y, + face.width, + face.height, + request.width as f32, + request.height as f32, + ) + }) + .collect::>() + }; + let recogniser = schist_neural::get("face-embed").context("Recognition model missing")?; + let mut faces = Vec::new(); + for rect in boxes { + if rect.w <= 0.0 || rect.h <= 0.0 { + continue; + } + let (x, y, side) = rect.crop_square(1.1, request.width, request.height); + let crop = image::imageops::crop_imm(&image, x, y, side, side).to_image(); + let crop = image::imageops::resize(&crop, 112, 112, image::imageops::FilterType::Triangle); + let rgb: Vec = crop.as_raw().iter().map(|v| *v as f32 / 255.0).collect(); + let embedding = schist_neural::embed_face(&recogniser, &rgb)?; + ensure!(embedding.iter().all(|v| v.is_finite()), "Invalid embedding"); + faces.push(Face { rect, embedding }); + } + std::io::stdout().write_all(&serde_json::to_vec(&faces)?)?; + Ok(()) +} +fn main() { + if run().is_err() { + eprintln!("People processing failed"); + std::process::exit(1); + } +} diff --git a/docs/cloud.md b/docs/cloud.md new file mode 100644 index 00000000..513c74c2 --- /dev/null +++ b/docs/cloud.md @@ -0,0 +1,265 @@ +# Schist Cloud + +In the desktop app, choose **File → Schist Cloud → Sign into Schist Cloud…**, +or use the welcome screen button. The domain prompt starts with `schist.app`. +Enter another provider domain if needed; Continue opens its sign-in page in the +browser. Installed Linux, macOS and Windows packages register the +`schist://ig-callback` handler. A development binary can receive a callback with +`schist 'schist://ig-callback?state=…&code=…'` while the original app is running. + +The provider must serve `https:///.schist/auth-urls.json` and implement +the [Rust protocol types](../crates/cloud/src/protocol.rs) and +[transport contract](../crates/cloud/src/transport.rs) described below. The client supports desktop and the hosted WASM editor. +The [provider specification](https://gist.github.com/IAmJSD/f2d639079c5437424e693686490621c0) +describes capability discovery and native download behavior in sections 8.6–8.9. + +## Browser + +At **https://try.schist.app**, choose **File → Schist Cloud → Sign into Schist Cloud…**. +A popup signs into **schist.app** without replacing the editor page. WASM supports +only this provider and hosted editor origin; desktop retains the domain prompt. +The popup uses a state-bound, verifier-bound authorization code and validates the +message origin and popup identity. Credentials stay in tab memory, so reloading +requires signing in again. + +The browser shares the same live folders, buckets, search, filters, exports, +and collaborative image model with desktop. One binary MessagePack workspace +socket carries queries and edits. Browser fetch handles original uploads and +downloads; the provider proxies S3 downloads so storage-bucket CORS is unnecessary. +The legacy generation stream still uses its own per-job socket. + +**Upload files…** selects multiple files; **Upload folder…** preserves relative +paths and can import directly into a cloud folder or bucket. Each selection is +limited to 512 MiB in browser memory; the provider's upload limits still apply. +The local filesystem gallery and native drag integration remain desktop features. + +Files over 100 MiB use multipart uploads (up to 5 GiB per file). Desktop hashes +the source with a bounded buffer, then reads/uploads 8 MiB chunks directly from +disk. Each failed chunk is retried up to three times; selecting the same file +again resumes its saved parts for 24 hours, including after restarting the app. +The resume identity includes the content and destination, preventing changed +files from mixing with an earlier upload. Byte progress appears in the status +bar. The hosted WASM app retains its 512 MiB selection memory limit. + +Imports check the size before loading file contents. Files over 5 GiB, +empty, or unreadable files are skipped without discarding the rest of the batch; +the final status reports uploaded/skipped counts and the first skipped file's +reason. A source that grows during reading is checked again. This applies to both +desktop and WASM imports. Connection/provider failures still stop the operation, +and already committed files remain in Cloud. +Remote folders and assets can be dragged into remote buckets in either build. +Downloads use the browser's download flow. Edits survive socket reconnects in the +open tab, but filesystem recovery across page reloads is not available in WASM. + +## Gallery and documents + +After sign-in the gallery sidebar lists the cloud alongside the local library: +under FOLDERS, a ☁ **Schist Cloud** root with the cloud folders as a tree +beneath it; under BUCKETS, the cloud buckets with the same ☁ badge. Signing in +does not change the screen — an open image stays open, and the rows simply +appear. **+ Add folder…** and **+ New bucket** ask whether you mean this +computer or Schist Cloud once a cloud is signed in. Clicking a cloud row shows +that remote library in the same +grid the local gallery uses — thumbnails under month headers, or under folder +headers with the sidebar's Group By chips — with the same selection, arrow keys, +drags and right-click menu, and the tray's Edit, Download… and size slider. The +search box in the top strip searches the remote library as you type (names and +tags, ranked by the provider); the map filter applies to the cloud too, with +its chip in the strip while it is on. Escape clears the search. Pages of 200 photos have links under the grid. + +The sidebar's VIEW, GROUP BY and PEOPLE sections stay whichever room is up. +World Map plots every located photo in the folder, bucket or search on show +(not just the current page, up to 5000) by the `location` each asset carries, +with the same markers, strip and marker menu as local photos; marker +thumbnails load on demand. The PEOPLE list +shows the local people and, beneath them, the cloud's people with a ☁ badge; +right-click a cloud person to rename, merge or forget them. + +Whole folders move in either direction from the right-click menus. A cloud +folder row (and the ☁ Schist Cloud root) offers Upload files here…, Upload +folder here… and Download folder… (or Download everything…), which recreates +the cloud's sub-folders under a folder you choose. A watched local folder's +row offers Upload to Schist Cloud…, which asks for the destination cloud +folder and keeps the local sub-folders. + +Deleting a cloud folder asks whether to take its contents along: unticked, +only an empty folder goes; ticked, every photo and sub-folder inside is removed +for good. Folder rows also offer Rename, New folder inside and Delete on their right-click +menu; bucket rows offer Edit, Select all, Save all as ZIP, Process all (the +originals are fetched to a scratch folder and the batch dialog saves results to +a folder you choose), Move all to a cloud folder, Clear added photos and +Delete; a photo's menu has Edit, +Download, bucket membership and Delete from Schist Cloud. Folder and bucket +lists update through live subscriptions and page at 500; a Find link appears +when a library outgrows one page. A cloud bucket uses the same New Bucket +dialog as a local one — a name, a search, and an area drawn on the map — and +the provider keeps it filled by that rule. The browser build shows the same +room with only the cloud in it, and asks for the name and search only. + +Thumbnails come from the `thumbnail_url` on each asset in a workspace snapshot: +a signed download ticket the provider serves without credentials (format +`thumbnail`, revision-bound). The client fetches a page's worth through a +small worker pool, decodes WebP/PNG/JPEG at up to 256 px, and shows "no +preview" for an asset whose URL is absent or whose fetch failed. + +Drag local gallery photos, a watched local folder, or files/folders from the file +manager into a cloud bucket or folder to upload them. Local originals remain in +place; hidden entries such as the gallery's own `.schist` sidecar and version +folders are left out. Files the library already holds are left out too: each +file is hashed (SHA-256) and the provider is asked which digests it has, so a +folder dropped twice uploads nothing the second time, though a bucket drop +still adds the existing photos to the bucket. The rest travel in +gzip-compressed batches of up to 48 MiB or 250 files +each, so a big drop is several payloads and a failed one can be retried alone. +On desktop the packing runs ahead on its own thread — a couple of batches are +read and compressed while one goes up — so the network never waits on the +disk; the tray shows a bar with the count uploaded so far. If the connection +drops mid-way the upload waits for it to come back (up to about four hours) +and resumes where it stopped: batches repeat with the same mutation IDs so +the provider answers a repeated commit from its record, and large files +continue from the parts already stored. A provider without batch +uploads receives the files one at a time. +Directory uploads retain relative paths and skip symlinks. Drag remote photos or +a remote folder into a bucket to add references without re-uploading. Smart +buckets combine manually added members with their saved rule's matches. Removing +manual membership can leave a photo visible if it still matches the rule. +Upload Files and Upload Current Document also offer a cloud folder +destination. + +Double-clicking a remote asset downloads its export and joins its collaborative +document. **The asset ID is the document ID on the wire; `Asset.folder_id` +associates it with a folder.** Opening does not create or relocate an asset. +Uploads set `folder_id` before the returned asset is bound to the editor. +Bucket membership is independent of folder placement. + +Select one cloud photo and choose **Download selected…** to save the current +editable document, its original imported file, or an export format advertised by +the provider. Import-only codecs are excluded from export choices. Providers +without capability discovery still support downloading the current document. +Downloads preserve the ticket revision and HTTP content metadata. The suggested +filename follows Content-Disposition, with Content-Type used as a fallback; +opening always identifies the actual file format by its bytes. A revision conflict +(HTTP 409) obtains a fresh ticket, with at most three download attempts. + +Collaborative edits sync automatically. Save waits for acknowledgement; a tab is +marked saved only when its latest local edits have been acknowledged. Undo/redo +tracks the local participant's changes. Reconnect joins with a Yjs state vector +and exchanges missing updates. Closing a tab leaves its document room. +A provider's `document_error` permanently stops that binding and ignores late +updates and acknowledgements while retaining local edits. Reconnect does not +restart it; explicitly reopen the asset from the cloud gallery to try again. + +Credentials use the operating system credential store. Crash recovery stores +MessagePack checkpoints under Schist's cloud state directory, including edits +made before joining a document; reopen the cloud asset to merge them. Writes are +serialized and atomically replace the previous checkpoint. Local selection, +history-brush sources and other editor-only state stay local to the tab. + +## Wire format + +`crates/cloud` owns one authenticated WSS connection for folders, buckets, +queries, mutations and all open collaborative documents. Messages are MessagePack +maps; binary updates and state vectors use MessagePack `bin`, never Base64 or +numeric arrays. Ordinary asset transfers use signed HTTPS URLs obtained through +that socket. Credentials are not forwarded to those transfer URLs. + +After connecting, the client requests `workspace.capabilities` before joining +documents. It checks support for `schist.image.v1`, applies the advertised frame +ceiling to whole encoded envelopes, and checks the merged local Yjs state against +the document ceiling before sending edits. The server remains authoritative for +limits after merging concurrent edits. Only `method_not_found` enables fallback +to the original protocol without capability discovery; other discovery errors +are reported. Explicit download formats require advertised support. + +The client restores subscriptions after reconnecting, rejects obsolete query +snapshots, refreshes credentials and detects dead connections. Unacknowledged +ordinary mutations fail visibly and are not automatically replayed. Collaborative +updates are reconciled through state vectors. Requests carry separate request +and mutation IDs: request IDs correlate replies, while mutation IDs identify +operations for server-side deduplication. + +The original image-generation API is also supported: provider-defined text and +choice fields, live text previews, streamed result slots and cancellation. Its +legacy per-generation socket uses the [generation API's](../crates/cloud/src/generation.rs) +JSON/binary slot format; it is +separate from the shared workspace socket. + +### Shared image representation + +The workspace protocol transports opaque Yjs v1 updates. The native editor supplies an image +model using Yrs, interoperable with Yjs, in a root map named `schist.image.v1`. +Every value in this map is binary: + +| Key | Value | +| --- | --- | +| `document/size` | MessagePack tuple: width, height, resolution DPI | +| `document/title` | UTF-8 | +| `document/metadata` | Layerless 1×1 PSD carrying document metadata | +| `document/comps` | MessagePack layer comps with stable layer references | +| `layer//placement` | MessagePack tuple: parent ID (`root` at top level), sibling rank | +| `layer//template` | Single-layer 1×1 PSD preserving layer kind and advanced properties, without raster/mask tiles or children | +| `layer//name` | UTF-8 | +| `layer//visible`, `locked`, `clipping` | One boolean byte | +| `layer//opacity`, `fill` | Little-endian float32 | +| `layer//blend` | Four-byte PSD blend key | +| `layer//pixels//` | Depth byte (8, 16, 32), then a complete RGBA tile; multibyte samples are little-endian | +| `layer//mask//` | Complete single-channel 8-bit mask tile | + +Independent properties and tiles merge independently. Concurrent writes to the +same key use Yjs conflict resolution; painting the same tile is not a per-pixel +merge. Existing layers receive deterministic `seed/…` IDs; newly inserted layers +receive UUIDs. The initial seed reserves Yjs client ID 1, and must be generated +from the same initial export. An existing room takes precedence over that export. +Participants implementing this model must reserve that client ID too. + +The provider must persist the Yjs room, enforce access to its asset, and include +the folder ID when returning assets. To make collaboration visible in later +downloads, thumbnails, search indexes and other clients, the provider must +materialize this image model or use a compatible exporter. A generic opaque-Yjs +relay alone cannot produce those image exports. + +Workspace messages are capped at 256 MiB, or the provider's lower advertised limit. +Schist Cloud currently advertises a separate 128 MiB merged-document ceiling. +Oversized updates remain local and show an error; larger documents need protocol +chunking before they can sync. Asset downloads are capped at 512 MiB. Generation +limits are 128 slots, 64 MiB per image and 256 MiB of retained result bytes. + +## Checks + +`make check-cloud` runs protocol, authentication validation, real local-WebSocket +reconnect/multiplexing and limit checks, local HTTP download-conflict and metadata +tests, collaborative merge, local-only undo, recovery and tile round trips. It +also tests terminated document bindings in the app, then checks the desktop app. +`make app PROFILE=debug` builds +the desktop binary. Live provider authentication and server-side persistence +require a running compatible service and account. + +### Shared desktop/provider library + +The Yrs image model lives in `schist-document`; `schist-cloud::document` re-exports it +for compatibility. Desktop, WASM, and the provider use the same model. The provider +can link the library or run its standalone worker to import built-in formats, +validate edits, and materialize downloads. See [document-library.md](document-library.md). + +## People and upload screening + +Cloud People lives in the same gallery sidebar as the local People album, scoped +to the current cloud folder or bucket. Faces are found automatically in cleared cloud +uploads with the same UltraFace/SFace models as desktop. Space or “View & name +people” opens the cloud face viewer: click a box to name it, draw a missed face, +accept a suggestion, or use “Not them”/“Not a face”. Rename can merge people; +Forget removes names without deleting photos. Local People data remains local. + +Screening and blocked-upload counts arrive with live catalogue snapshots. A newly +uploaded editor document stays local while its cloud copy is screened; open the +cloud copy after it appears to begin collaboration. A provider account denial +stops reconnect attempts, clears the cloud catalogue/thumbnail cache and stored +credentials, and detaches remote documents while preserving local edits. Shield, +Slack and CyberTipline credentials are server-only. + +Before importing, Schist checks the whole selection against current cloud storage, +including other uploads' reservations. If it will not fit, a dialog shows the +space needed, available space, and how much to free or upgrade; no file bytes are +sent. Interrupted multipart uploads are matched by content and destination so +resuming does not count their reserved space twice. The check is advisory: another +device can use space afterward, and the server still enforces quota at upload. diff --git a/docs/document-library.md b/docs/document-library.md new file mode 100644 index 00000000..ffbf9199 --- /dev/null +++ b/docs/document-library.md @@ -0,0 +1,39 @@ +# Shared document library + +`schist-document` is a headless Rust library shared by the desktop cloud client and Schist Cloud's Node server. It depends on core, codecs, the CPU compositor and Yrs. It has no GPUI, GPU, network, authentication or payment dependency. The image model also supports WASM with a browser-compatible clock. + +```rust +let codecs = schist_document::registry(); +let doc = schist_document::import(&codecs, &file_bytes, "image.psd")?; +let state = schist_document::SharedDocument::new(&doc)?.full_state(); +// Persist/exchange this Yjs v1 update. Later, after merging client updates: +let edited = schist_document::materialize(&state)?; +let export = schist_document::export(&codecs, &edited, "psd")?; +``` + +`crates/cloud::document::SharedDocument` remains a compatibility re-export. The PSD plugin wrapper now lives in `schist-codecs-common`, so the UI and headless registry use the same wrapper and registration order. `registry()` returns the normal extensible `PluginRegistry`; a host can register additional trusted codecs. + +The library includes every built-in desktop codec. HEIC requires libheif and a compatible HEVC decoder at runtime; no library is downloaded automatically by the worker. RAW and HEIC can be imported and collaboratively edited even though their codecs cannot export. Export to layered PSD/PSB to keep an editable document, or explicitly choose another available encoder. The desktop codecs' existing support/fidelity limits still apply. Pixel merging remains tile-level Yjs conflict resolution, not per-pixel blending. + +## Build and verify + +```sh +make document-worker PROFILE=debug +make check-document +make check-cloud +# Deployment binary: +make document-worker PROFILE=release +``` + +The standalone `target//schist-document-worker` links this library. It reads a single binary input from stdin and writes one MessagePack map to stdout. Errors go to stderr with a nonzero exit code. It never opens a GUI or makes network requests. + +| Command | stdin | stdout | +| --- | --- | --- | +| `formats` | empty | array of codec capabilities | +| `import ` | original bytes | model, width, height, state (Yjs bin) | +| `validate` | Yjs v1 state | model, width, height, or an error | +| `export ` | Yjs v1 state | model, width, height, bytes (bin), extension, mime_type | + +The worker accepts up to 256 MiB input. Hosts should impose their own memory/CPU and concurrency limits; the Node host limits runtime to 25 seconds and two concurrent workers, and accepts up to 128 MiB merged collaboration state. Build the worker and Node host in the same base distribution for compatible system libraries; Schist Cloud's Dockerfile does this with an additional `schist` build context. Match installed fonts if identical text rendering matters across machines. + +The `schist.image.v1` format is documented in [cloud.md](cloud.md) and [the client specification](ts-draft-client.ts). Its deterministic seed reserves Yjs client ID 1. Never seed an existing native room from a newly rendered export, since that could give different data the same CRDT item identities. diff --git a/docs/gallery.md b/docs/gallery.md index b3454104..3b696a85 100644 --- a/docs/gallery.md +++ b/docs/gallery.md @@ -6,6 +6,10 @@ a desktop feature — a browser tab has no folders to watch and no cameras to mount, so the whole subsystem is compiled out of the web build and its menu entries with it. +The gallery also supports [Schist Cloud](cloud.md): choose **Sign into Schist +Cloud…**, with `schist.app` prefilled, to browse remote folders and buckets, +search their live contents, upload local folders and edit collaboratively. + ## Boot A launch with nothing to open lands in the gallery, empty or not — as diff --git a/docs/web.md b/docs/web.md index caf264ad..df1d40d9 100644 --- a/docs/web.md +++ b/docs/web.md @@ -112,6 +112,15 @@ Compiled out entirely, with the reason: The menu entries for those features are filtered out of the web build rather than left to fail. +## Schist Cloud + +The hosted editor at **https://try.schist.app** supports Schist Cloud sign-in, +live folders and buckets, searches and filters, file/folder uploads, downloads, +collaborative editing, and generation. Browser cloud connections are restricted +to **schist.app**; custom providers remain available on desktop. Sign-in uses a +popup, preserving open documents. See [Cloud](cloud.md#browser) for the browser +flow, file handling, and session lifetime. + ## Known gaps Camera raws open through the pure-Rust `schist-codec-raw` decoder, the diff --git a/packaging/linux/schist.desktop b/packaging/linux/schist.desktop index f82f0ecf..168ec112 100644 --- a/packaging/linux/schist.desktop +++ b/packaging/linux/schist.desktop @@ -3,8 +3,8 @@ Type=Application Name=Schist GenericName=Image Editor Comment=Layered image editing with PSD support -Exec=schist %f +Exec=schist %u Icon=com.infrawrench.schist Terminal=false Categories=Graphics;2DGraphics;RasterGraphics; -MimeType=image/vnd.adobe.photoshop;application/x-affinity-photo;application/x-affinity-designer;application/x-affinity-publisher;application/x-affinity-document;image/png;image/jpeg;image/tiff;image/webp;image/heif;image/heic;image/x-dcraw;image/x-adobe-dng;image/x-canon-cr2;image/x-canon-cr3;image/x-canon-crw;image/x-fuji-raf;image/x-nikon-nef;image/x-nikon-nrw;image/x-olympus-orf;image/x-panasonic-rw2;image/x-panasonic-rw;image/x-pentax-pef;image/x-sony-arw;image/x-sony-sr2;image/x-sony-srf;image/x-minolta-mrw;image/x-sigma-x3f;image/x-kodak-dcr;image/x-kodak-kdc; +MimeType=x-scheme-handler/schist;image/vnd.adobe.photoshop;application/x-affinity-photo;application/x-affinity-designer;application/x-affinity-publisher;application/x-affinity-document;image/png;image/jpeg;image/tiff;image/webp;image/heif;image/heic;image/x-dcraw;image/x-adobe-dng;image/x-canon-cr2;image/x-canon-cr3;image/x-canon-crw;image/x-fuji-raf;image/x-nikon-nef;image/x-nikon-nrw;image/x-olympus-orf;image/x-panasonic-rw2;image/x-panasonic-rw;image/x-pentax-pef;image/x-sony-arw;image/x-sony-sr2;image/x-sony-srf;image/x-minolta-mrw;image/x-sigma-x3f;image/x-kodak-dcr;image/x-kodak-kdc; diff --git a/packaging/macos/Info.plist b/packaging/macos/Info.plist index d35d8a15..0ec7a890 100644 --- a/packaging/macos/Info.plist +++ b/packaging/macos/Info.plist @@ -80,6 +80,9 @@ + CFBundleURLTypes + CFBundleURLNameSchist Cloud sign-in + CFBundleURLSchemesschist CFBundleDocumentTypes diff --git a/packaging/windows/installer.nsi b/packaging/windows/installer.nsi index 6c4df478..f394b889 100644 --- a/packaging/windows/installer.nsi +++ b/packaging/windows/installer.nsi @@ -44,6 +44,9 @@ Section "Schist" File "schist.ico" WriteRegStr HKLM "Software\Schist" "InstallDir" "$INSTDIR" + WriteRegStr HKCR "schist" "" "URL:Schist Cloud" + WriteRegStr HKCR "schist" "URL Protocol" "" + WriteRegStr HKCR "schist\shell\open\command" "" '"$INSTDIR\schist.exe" "%1"' ; Add/Remove Programs entry. WriteRegStr HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Schist" \ "DisplayName" "Schist" @@ -78,6 +81,7 @@ Section "Uninstall" Delete "$INSTDIR\uninstall.exe" Delete "$SMPROGRAMS\Schist.lnk" RMDir "$INSTDIR" + DeleteRegKey HKCR "schist" DeleteRegKey HKLM "Software\Schist" DeleteRegKey HKLM "Software\Microsoft\Windows\CurrentVersion\Uninstall\Schist" !insertmacro UnassociateExt "psd" diff --git a/plugins/codecs-common/Cargo.toml b/plugins/codecs-common/Cargo.toml index 9a576702..8f02f93d 100644 --- a/plugins/codecs-common/Cargo.toml +++ b/plugins/codecs-common/Cargo.toml @@ -6,6 +6,7 @@ license.workspace = true [dependencies] schist-core.workspace = true +schist-codec-psd.workspace = true schist-plugin-api.workspace = true schist-codec-affinity.workspace = true anyhow.workspace = true diff --git a/plugins/codecs-common/src/lib.rs b/plugins/codecs-common/src/lib.rs index 458dfdfd..f27dd788 100644 --- a/plugins/codecs-common/src/lib.rs +++ b/plugins/codecs-common/src/lib.rs @@ -5,10 +5,12 @@ //! "Background" layer and export flattens through the compositor. pub use affinity::AffinityCodec; +mod psd; use anyhow::Context as _; #[cfg(not(target_arch = "wasm32"))] pub use heif::HeifCodec; use image::ImageFormat; +pub use psd::{PsdCodec, PsdPlugin}; pub use raw::RawCodec; use schist_color::Depth; use schist_core::{blit_rgba8, blit_rgba_f32, Document, IntRect, Layer}; diff --git a/plugins/codecs-common/src/psd.rs b/plugins/codecs-common/src/psd.rs new file mode 100644 index 00000000..769c88df --- /dev/null +++ b/plugins/codecs-common/src/psd.rs @@ -0,0 +1,39 @@ +use schist_plugin_api::{CodecPlugin, PluginManifest, PluginRegistry}; + +/// PSD/PSB import and export via `schist-codec-psd`. +pub struct PsdCodec; + +impl CodecPlugin for PsdCodec { + fn id(&self) -> &'static str { + "codec.psd" + } + fn name(&self) -> &'static str { + "Photoshop PSD" + } + fn extensions(&self) -> &'static [&'static str] { + &["psd", "psb"] + } + fn probe(&self, bytes: &[u8]) -> bool { + schist_codec_psd::is_psd(bytes) + } + fn import(&self, bytes: &[u8]) -> anyhow::Result { + Ok(schist_codec_psd::read_psd(bytes)?) + } + fn can_export(&self) -> bool { + true + } + fn export(&self, doc: &schist_core::Document) -> anyhow::Result> { + Ok(schist_codec_psd::write_psd(doc)?) + } +} + +pub struct PsdPlugin; + +impl PluginManifest for PsdPlugin { + fn id(&self) -> &'static str { + "schist.codec-psd" + } + fn register(&self, registry: &mut PluginRegistry) { + registry.register_codec(Box::new(PsdCodec)); + } +}