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