Compare commits

..

24 Commits

Author SHA1 Message Date
Micah
441c469966 Release Rojo v7.6.0 (#1125) 2025-10-10 19:17:55 -07:00
Micah
f3c423d77d Fix the various lints (#1124) 2025-10-10 13:00:56 -07:00
Micah
beb497878b Add flag for skipping git initialization to init command (#1122) 2025-10-07 17:12:22 -07:00
Micah
6ea95d487c Refactor init command (#1117) 2025-09-30 14:38:38 -07:00
Micah
80a381dbb1 Use SerializationService as a fallback for when patch application fails (#1030) 2025-09-21 15:09:20 -07:00
KAS
59e36491a5 Fix a grammar error and a typo (#1113) 2025-09-16 11:00:34 -07:00
Micah
c1326ba06e Add Arm64 builds to CI/Releases + build on ubuntu 22.04 (#1098) 2025-08-30 14:10:59 -07:00
Micah
e2633126ee Change Foreman to Rokit in CONTRIBUTING.md (#1110) 2025-08-30 13:42:52 -07:00
Micah
5f33435f3c Move to using Rokit, update tools, and don't install unnecessary tools (#1109) 2025-08-29 18:45:15 -07:00
boatbomber
54e0ff230b Improvements to sync reminder UX (#1096) 2025-08-28 17:15:34 -07:00
wad
4e9e6233ff fix: apply gameId and placeId only after initial sync (#1104) 2025-08-15 18:12:36 -07:00
Micah
0056849b51 Put Rojo version in crash message (#1101) 2025-08-13 15:46:08 -07:00
ffrostfall
2ddb21ec5f Add option for emitting absolute paths to rojo sourcemap (#1092)
Co-authored-by: Micah <micah@uplift.games>
2025-08-04 11:33:35 -07:00
Micah
a4eb65ca3f Add YAML middleware that behaves like TOML and JSON (#1093) 2025-08-02 20:58:13 -07:00
Sebastian Stachowicz
3002d250a1 Fix Table diff colors (#1084) 2025-07-31 19:36:03 -07:00
Micah
9598553e5d Normalize paths in sourcemap generation (#1085) 2025-07-31 09:19:57 -07:00
Sebastian Stachowicz
7f68d9887b Fixed nil -> nil props showing up as failing in patch visualizer (plugin) (#1081) 2025-07-25 15:27:11 -07:00
Micah
e092a7301f Change background color of web UI to gray (#1080) 2025-07-25 15:04:42 -07:00
Cameron Campbell
6dfdfbe514 Cache Rust Dependencies in release.yml. (#1079) 2025-07-22 16:13:23 -07:00
morosanu
7860f2717f Fix auto connect for play mode (#1066) 2025-07-22 15:12:16 -07:00
boatbomber
60f19df9a0 Show update indicator on version header (#1069) 2025-06-21 02:53:45 +00:00
boatbomber
951f0cda0b Show the plugin version on the Error page (#1068) 2025-06-20 18:28:04 -07:00
Micah
227042d6b1 Add current maintainers to author field of Cargo.toml files (#1053) 2025-05-21 20:55:39 -07:00
Micah
b2c4f550ee Release v7.5.1 (#1035) 2025-04-25 13:56:01 -07:00
83 changed files with 9564 additions and 1300 deletions

View File

@@ -16,7 +16,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
os: [ubuntu-22.04, windows-latest, macos-latest, windows-11-arm, ubuntu-22.04-arm]
steps:
- uses: actions/checkout@v4
@@ -26,13 +26,14 @@ jobs:
- name: Install Rust
uses: dtolnay/rust-toolchain@stable
- name: Rust cache
uses: Swatinem/rust-cache@v2
- name: Setup Aftman
uses: ok-nick/setup-aftman@v0.4.2
- name: Restore Rust Cache
uses: actions/cache/restore@v4
with:
version: 'v0.3.0'
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ matrix.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Build
run: cargo build --locked --verbose
@@ -40,6 +41,15 @@ jobs:
- name: Test
run: cargo test --locked --verbose
- name: Save Rust Cache
uses: actions/cache/save@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ matrix.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
msrv:
name: Check MSRV
runs-on: ubuntu-latest
@@ -50,19 +60,29 @@ jobs:
submodules: true
- name: Install Rust
uses: dtolnay/rust-toolchain@1.70.0
uses: dtolnay/rust-toolchain@1.83.0
- name: Rust cache
uses: Swatinem/rust-cache@v2
- name: Setup Aftman
uses: ok-nick/setup-aftman@v0.4.2
- name: Restore Rust Cache
uses: actions/cache/restore@v4
with:
version: 'v0.3.0'
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Build
run: cargo build --locked --verbose
- name: Save Rust Cache
uses: actions/cache/save@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
lint:
name: Rustfmt, Clippy, Stylua, & Selene
runs-on: ubuntu-latest
@@ -77,13 +97,19 @@ jobs:
with:
components: rustfmt, clippy
- name: Rust cache
uses: Swatinem/rust-cache@v2
- name: Setup Aftman
uses: ok-nick/setup-aftman@v0.4.2
- name: Restore Rust Cache
uses: actions/cache/restore@v4
with:
version: 'v0.3.0'
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Setup Rokit
uses: CompeyDev/setup-rokit@v0.1.2
with:
version: 'v1.1.0'
- name: Stylua
run: stylua --check plugin/src
@@ -97,3 +123,11 @@ jobs:
- name: Clippy
run: cargo clippy
- name: Save Rust Cache
uses: actions/cache/save@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}

View File

@@ -25,10 +25,10 @@ jobs:
with:
submodules: true
- name: Setup Aftman
uses: ok-nick/setup-aftman@v0.4.2
- name: Setup Rokit
uses: CompeyDev/setup-rokit@v0.1.2
with:
version: 'v0.3.0'
version: 'v1.1.0'
- name: Build Plugin
run: rojo build plugin.project.json --output Rojo.rbxm
@@ -53,15 +53,25 @@ jobs:
# https://doc.rust-lang.org/rustc/platform-support.html
include:
- host: linux
os: ubuntu-latest
os: ubuntu-22.04
target: x86_64-unknown-linux-gnu
label: linux-x86_64
- host: linux
os: ubuntu-22.04-arm
target: aarch64-unknown-linux-gnu
label: linux-aarch64
- host: windows
os: windows-latest
target: x86_64-pc-windows-msvc
label: windows-x86_64
- host: windows
os: windows-11-arm
target: aarch64-pc-windows-msvc
label: windows-aarch64
- host: macos
os: macos-latest
target: x86_64-apple-darwin
@@ -86,17 +96,26 @@ jobs:
with:
targets: ${{ matrix.target }}
- name: Setup Aftman
uses: ok-nick/setup-aftman@v0.4.2
- name: Restore Rust Cache
uses: actions/cache/restore@v4
with:
version: 'v0.3.0'
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ matrix.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Build Release
run: cargo build --release --locked --verbose --target ${{ matrix.target }}
env:
# Build into a known directory so we can find our build artifact more
# easily.
CARGO_TARGET_DIR: output
- name: Save Rust Cache
uses: actions/cache/save@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ matrix.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Generate Artifact Name
shell: bash
@@ -113,11 +132,11 @@ jobs:
mkdir staging
if [ "${{ matrix.host }}" = "windows" ]; then
cp "output/${{ matrix.target }}/release/$BIN.exe" staging/
cp "target/${{ matrix.target }}/release/$BIN.exe" staging/
cd staging
7z a ../$ARTIFACT_NAME *
else
cp "output/${{ matrix.target }}/release/$BIN" staging/
cp "target/${{ matrix.target }}/release/$BIN" staging/
cd staging
zip ../$ARTIFACT_NAME *
fi

View File

@@ -1,5 +1,40 @@
# Rojo Changelog
## 7.6.0 - October 10th, 2025
* Added flag to `rojo init` to skip initializing a git repository ([#1122])
* Added fallback method for when an Instance can't be synced through normal means ([#1030])
This should make it possible to sync `MeshParts` and `Unions`!
The fallback involves deleting and recreating Instances. This will break
properties that reference them that Rojo does not know about, so be weary.
* Add auto-reconnect and improve UX for sync reminders ([#1096])
* Add support for syncing `yml` and `yaml` files (behaves similar to JSON and TOML) ([#1093])
* Fixed colors of Table diff ([#1084])
* Fixed `sourcemap` command outputting paths with OS-specific path separators ([#1085])
* Fixed nil -> nil properties showing up as failing to sync in plugin's patch visualizer ([#1081])
* Changed the background of the server's in-browser UI to be gray instead of white ([#1080])
* Fixed `Auto Connect Playtest Server` no longer functioning due to Roblox change ([#1066])
* Added an update indicator to the version header when a new version of the plugin is available. ([#1069])
* Added `--absolute` flag to the sourcemap subcommand, which will emit absolute paths instead of relative paths. ([#1092])
* Fixed applying `gameId` and `placeId` before initial sync was accepted ([#1104])
[#1122]: https://github.com/rojo-rbx/rojo/pull/1122
[#1030]: https://github.com/rojo-rbx/rojo/pull/1030
[#1096]: https://github.com/rojo-rbx/rojo/pull/1096
[#1093]: https://github.com/rojo-rbx/rojo/pull/1093
[#1084]: https://github.com/rojo-rbx/rojo/pull/1084
[#1085]: https://github.com/rojo-rbx/rojo/pull/1085
[#1081]: https://github.com/rojo-rbx/rojo/pull/1081
[#1080]: https://github.com/rojo-rbx/rojo/pull/1080
[#1066]: https://github.com/rojo-rbx/rojo/pull/1066
[#1069]: https://github.com/rojo-rbx/rojo/pull/1069
[#1092]: https://github.com/rojo-rbx/rojo/pull/1092
[#1104]: https://github.com/rojo-rbx/rojo/pull/1104
## 7.5.1 - April 25th, 2025
* Fixed output spam related to `Instance.Capabilities` in the plugin
## 7.5.0 - April 25th, 2025
* Fixed an edge case that caused model pivots to not be built correctly in some cases ([#1027])
* Add `blockedPlaceIds` project config field to allow blocking place ids from being live synced ([#1021])
@@ -693,7 +728,7 @@ This is a general maintenance release for the Rojo 0.5.x release series.
## [0.5.0 Alpha 11](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.0-alpha.11) (May 29, 2019)
* Added support for implicit property values in JSON model files ([#154](https://github.com/rojo-rbx/rojo/pull/154))
* `Content` propertyes can now be specified in projects and model files as regular string literals.
* `Content` properties can now be specified in projects and model files as regular string literals.
* Added support for `BrickColor` properties.
* Added support for properties added in client release 384, like `Lighting.Technology` being set to `"ShadowMap"`.
* Improved performance when working with XML models and places

View File

@@ -15,7 +15,7 @@ You'll want these tools to work on Rojo:
* Latest stable Rust compiler
* Latest stable [Rojo](https://github.com/rojo-rbx/rojo)
* [Foreman](https://github.com/Roblox/foreman)
* [Rokit](https://github.com/rojo-rbx/rokit)
* [Luau Language Server](https://github.com/JohnnyMorganz/luau-lsp) (Only needed if working on the Studio plugin.)
When working on the Studio plugin, we recommend using this command to automatically rebuild the plugin when you save a change:
@@ -37,7 +37,7 @@ bash scripts/unit-test-plugin.sh
## Documentation
Documentation impacts way more people than the individual lines of code we write.
If you find any problems in documentation, including typos, bad grammar, misleading phrasing, or missing content, feel free to file issues and pull requests to fix them.
If you find any problems in the documentation, including typos, bad grammar, misleading phrasing, or missing content, feel free to file issues and pull requests to fix them.
## Bug Reports and Feature Requests
Most of the tools around Rojo try to be clear when an issue is a bug. Even if they aren't, sometimes things don't work quite right.

136
Cargo.lock generated
View File

@@ -45,6 +45,12 @@ version = "1.0.80"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ad32ce52e4161730f7098c077cd2ed6229b5804ccf99e5366be1ab72a98b4e1"
[[package]]
name = "arraydeque"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
[[package]]
name = "arrayref"
version = "0.3.7"
@@ -395,6 +401,12 @@ dependencies = [
"memchr",
]
[[package]]
name = "data-encoding"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
[[package]]
name = "diff"
version = "0.1.13"
@@ -418,7 +430,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13aea89a5c93364a98e9b37b2fa237effbb694d5cfe01c5b70941f7eb087d5e3"
dependencies = [
"cfg-if 0.1.10",
"dirs-sys",
"dirs-sys 0.3.7",
]
[[package]]
name = "dirs"
version = "5.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225"
dependencies = [
"dirs-sys 0.4.1",
]
[[package]]
@@ -432,6 +453,18 @@ dependencies = [
"winapi 0.3.9",
]
[[package]]
name = "dirs-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c"
dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.48.0",
]
[[package]]
name = "either"
version = "1.10.0"
@@ -519,6 +552,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "form_urlencoded"
version = "1.2.1"
@@ -751,6 +790,24 @@ version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604"
[[package]]
name = "hashbrown"
version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5"
dependencies = [
"foldhash",
]
[[package]]
name = "hashlink"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
dependencies = [
"hashbrown 0.15.4",
]
[[package]]
name = "heck"
version = "0.4.1"
@@ -1057,23 +1114,12 @@ dependencies = [
]
[[package]]
name = "lz4"
version = "1.24.0"
name = "lz4_flex"
version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e9e2dd86df36ce760a60f6ff6ad526f7ba1f14ba0356f8254fb6905e6494df1"
checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a"
dependencies = [
"libc",
"lz4-sys",
]
[[package]]
name = "lz4-sys"
version = "1.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57d27b317e207b10f69f5e75494119e391a96f48861ae870d1da6edac98ca900"
dependencies = [
"cc",
"libc",
"twox-hash",
]
[[package]]
@@ -1265,6 +1311,12 @@ dependencies = [
"winapi 0.3.9",
]
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "os_str_bytes"
version = "6.6.1"
@@ -1563,13 +1615,13 @@ dependencies = [
[[package]]
name = "rbx_binary"
version = "1.0.0"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9573fee5e073d7b303f475c285197fdc8179468de66ca60ee115a58fbac99296"
checksum = "0d419f67c8012bf83569086e1208c541478b3b8e4f523deaa0b80d723fb5ef22"
dependencies = [
"ahash",
"log",
"lz4",
"lz4_flex",
"profiling",
"rbx_dom_weak",
"rbx_reflection",
@@ -1580,9 +1632,9 @@ dependencies = [
[[package]]
name = "rbx_dom_weak"
version = "3.0.0"
version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04425cf6e9376e5486f4fb35906c120d1b1b45618a490318cf563fab1fa230a9"
checksum = "bc74878a4a801afc8014b14ede4b38015a13de5d29ab0095d5ed284a744253f6"
dependencies = [
"ahash",
"rbx_types",
@@ -1592,9 +1644,9 @@ dependencies = [
[[package]]
name = "rbx_reflection"
version = "5.0.0"
version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b6d0d62baa613556b058a5f94a53b01cf0ccde0ea327ce03056e335b982e77e"
checksum = "565dd3430991f35443fa6d23cc239fade2110c5089deb6bae5de77c400df4fd2"
dependencies = [
"rbx_types",
"serde",
@@ -1603,11 +1655,12 @@ dependencies = [
[[package]]
name = "rbx_reflection_database"
version = "1.0.2+roblox-670"
version = "2.0.0+roblox-694"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5349b19e5e94fbcaba7a52175263ab64011e0a13f17ff57729f2f560ccdec615"
checksum = "844ceb61f23bad59b06d7299b69ff276579316eafa9857981da3012a6223f663"
dependencies = [
"lazy_static",
"dirs 5.0.1",
"log",
"rbx_reflection",
"rmp-serde",
"serde",
@@ -1615,9 +1668,9 @@ dependencies = [
[[package]]
name = "rbx_types"
version = "2.0.0"
version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78e4fdde46493def107e5f923d82e813dec9b3eef52c2f75fbad3a716023eda2"
checksum = "03220ffce2bd06ad04f77a003cb807f2e5b2a18e97623066a5ac735a978398af"
dependencies = [
"base64 0.13.1",
"bitflags 1.3.2",
@@ -1630,9 +1683,9 @@ dependencies = [
[[package]]
name = "rbx_xml"
version = "1.0.0"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb623833c31cc43bbdaeb32f5e91db8ecd63fc46e438d0d268baf9e61539cf1c"
checksum = "be6c302cefe9c92ed09bcbb075cd24379271de135b0af331409a64c2ea3646ee"
dependencies = [
"ahash",
"base64 0.13.1",
@@ -1824,14 +1877,14 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bb8c693a387f1ae8d2026d82d8b0c175cc4777b97c1f7b12fdb3be595bb13"
dependencies = [
"dirs",
"dirs 2.0.2",
"thiserror",
"winreg 0.6.2",
]
[[package]]
name = "rojo"
version = "7.5.0"
version = "7.6.0"
dependencies = [
"anyhow",
"backtrace",
@@ -1840,6 +1893,7 @@ dependencies = [
"criterion",
"crossbeam-channel",
"csv",
"data-encoding",
"embed-resource",
"env_logger",
"fs-err",
@@ -1879,6 +1933,7 @@ dependencies = [
"uuid",
"walkdir",
"winreg 0.10.1",
"yaml-rust2",
]
[[package]]
@@ -2416,6 +2471,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "twox-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
[[package]]
name = "typenum"
version = "1.17.0"
@@ -2877,6 +2938,17 @@ dependencies = [
"linked-hash-map",
]
[[package]]
name = "yaml-rust2"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ce2a4ff45552406d02501cea6c18d8a7e50228e7736a872951fe2fe75c91be7"
dependencies = [
"arraydeque",
"encoding_rs",
"hashlink",
]
[[package]]
name = "yansi"
version = "0.5.1"

View File

@@ -1,8 +1,12 @@
[package]
name = "rojo"
version = "7.5.0"
rust-version = "1.70.0"
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
version = "7.6.0"
rust-version = "1.83"
authors = [
"Lucien Greathouse <me@lpghatguy.com>",
"Micah Reid <git@dekkonot.com>",
"Ken Loeffler <kenloef@gmail.com>",
]
description = "Enables professional-grade development tools for Roblox developers"
license = "MPL-2.0"
homepage = "https://rojo.space"
@@ -51,11 +55,11 @@ memofs = { version = "0.3.0", path = "crates/memofs" }
# rbx_reflection_database = { path = "../rbx-dom/rbx_reflection_database" }
# rbx_xml = { path = "../rbx-dom/rbx_xml" }
rbx_binary = "1.0.0"
rbx_dom_weak = "3.0.0"
rbx_reflection = "5.0.0"
rbx_reflection_database = "1.0.2"
rbx_xml = "1.0.0"
rbx_binary = "2.0.0"
rbx_dom_weak = "4.0.0"
rbx_reflection = "6.0.0"
rbx_reflection_database = "2.0.0"
rbx_xml = "2.0.0"
anyhow = "1.0.80"
backtrace = "0.3.69"
@@ -89,6 +93,8 @@ tokio = { version = "1.36.0", features = ["rt", "rt-multi-thread"] }
uuid = { version = "1.7.0", features = ["v4", "serde"] }
clap = { version = "3.2.25", features = ["derive"] }
profiling = "1.0.15"
yaml-rust2 = "0.10.3"
data-encoding = "2.8.0"
[target.'cfg(windows)'.dependencies]
winreg = "0.10.1"

View File

@@ -40,7 +40,7 @@ Check out our [contribution guide](CONTRIBUTING.md) for detailed instructions fo
Pull requests are welcome!
Rojo supports Rust 1.70.0 and newer. The minimum supported version of Rust is based on the latest versions of the dependencies that Rojo has.
Rojo supports Rust 1.83 and newer. The minimum supported version of Rust is based on the latest versions of the dependencies that Rojo has.
## License
Rojo is available under the terms of the Mozilla Public License, Version 2.0. See [LICENSE.txt](LICENSE.txt) for details.

View File

@@ -1,5 +0,0 @@
[tools]
rojo = "rojo-rbx/rojo@7.4.1"
selene = "Kampfkarren/selene@0.27.1"
stylua = "JohnnyMorganz/stylua@0.20.0"
run-in-roblox = "rojo-rbx/run-in-roblox@0.3.0"

View File

@@ -17,6 +17,10 @@ html {
line-height: 1.4;
}
body {
background-color: #e7e7e7
}
img {
max-width:100%;
max-height:100%;

View File

@@ -2,4 +2,4 @@ return {
hello = function()
print("Hello world, from {project_name}!")
end,
}
}

View File

@@ -0,0 +1 @@
print("Hello world, from client!")

View File

@@ -0,0 +1 @@
print("Hello world, from server!")

View File

@@ -0,0 +1,3 @@
return function()
print("Hello, world!")
end

View File

@@ -0,0 +1 @@
print("Hello world, from plugin!")

View File

@@ -47,6 +47,7 @@ fn main() -> Result<(), anyhow::Error> {
let root_dir = PathBuf::from(env::var_os("CARGO_MANIFEST_DIR").unwrap());
let plugin_dir = root_dir.join("plugin");
let templates_dir = root_dir.join("assets").join("project-templates");
let our_version = Version::parse(env::var_os("CARGO_PKG_VERSION").unwrap().to_str().unwrap())?;
let plugin_version =
@@ -57,7 +58,9 @@ fn main() -> Result<(), anyhow::Error> {
"plugin version does not match Cargo version"
);
let snapshot = VfsSnapshot::dir(hashmap! {
let template_snapshot = snapshot_from_fs_path(&templates_dir)?;
let plugin_snapshot = VfsSnapshot::dir(hashmap! {
"default.project.json" => snapshot_from_fs_path(&root_dir.join("plugin.project.json"))?,
"plugin" => VfsSnapshot::dir(hashmap! {
"fmt" => snapshot_from_fs_path(&plugin_dir.join("fmt"))?,
@@ -70,10 +73,11 @@ fn main() -> Result<(), anyhow::Error> {
}),
});
let out_path = Path::new(&out_dir).join("plugin.bincode");
let out_file = File::create(out_path)?;
let template_file = File::create(Path::new(&out_dir).join("templates.bincode"))?;
let plugin_file = File::create(Path::new(&out_dir).join("plugin.bincode"))?;
bincode::serialize_into(out_file, &snapshot)?;
bincode::serialize_into(plugin_file, &plugin_snapshot)?;
bincode::serialize_into(template_file, &template_snapshot)?;
println!("cargo:rerun-if-changed=build/windows/rojo-manifest.rc");
println!("cargo:rerun-if-changed=build/windows/rojo.manifest");

View File

@@ -2,7 +2,11 @@
name = "memofs"
description = "Virtual filesystem with configurable backends."
version = "0.3.0"
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
authors = [
"Lucien Greathouse <me@lpghatguy.com>",
"Micah Reid <git@dekkonot.com>",
"Ken Loeffler <kenloef@gmail.com>",
]
edition = "2018"
readme = "README.md"
license = "MIT"

View File

@@ -228,23 +228,17 @@ impl VfsBackend for InMemoryFs {
}
fn must_be_file<T>(path: &Path) -> io::Result<T> {
Err(io::Error::new(
io::ErrorKind::Other,
format!(
"path {} was a directory, but must be a file",
path.display()
),
))
Err(io::Error::other(format!(
"path {} was a directory, but must be a file",
path.display()
)))
}
fn must_be_dir<T>(path: &Path) -> io::Result<T> {
Err(io::Error::new(
io::ErrorKind::Other,
format!(
"path {} was a file, but must be a directory",
path.display()
),
))
Err(io::Error::other(format!(
"path {} was a file, but must be a directory",
path.display()
)))
}
fn not_found<T>(path: &Path) -> io::Result<T> {

View File

@@ -15,45 +15,27 @@ impl NoopBackend {
impl VfsBackend for NoopBackend {
fn read(&mut self, _path: &Path) -> io::Result<Vec<u8>> {
Err(io::Error::new(
io::ErrorKind::Other,
"NoopBackend doesn't do anything",
))
Err(io::Error::other("NoopBackend doesn't do anything"))
}
fn write(&mut self, _path: &Path, _data: &[u8]) -> io::Result<()> {
Err(io::Error::new(
io::ErrorKind::Other,
"NoopBackend doesn't do anything",
))
Err(io::Error::other("NoopBackend doesn't do anything"))
}
fn read_dir(&mut self, _path: &Path) -> io::Result<ReadDir> {
Err(io::Error::new(
io::ErrorKind::Other,
"NoopBackend doesn't do anything",
))
Err(io::Error::other("NoopBackend doesn't do anything"))
}
fn remove_file(&mut self, _path: &Path) -> io::Result<()> {
Err(io::Error::new(
io::ErrorKind::Other,
"NoopBackend doesn't do anything",
))
Err(io::Error::other("NoopBackend doesn't do anything"))
}
fn remove_dir_all(&mut self, _path: &Path) -> io::Result<()> {
Err(io::Error::new(
io::ErrorKind::Other,
"NoopBackend doesn't do anything",
))
Err(io::Error::other("NoopBackend doesn't do anything"))
}
fn metadata(&mut self, _path: &Path) -> io::Result<Metadata> {
Err(io::Error::new(
io::ErrorKind::Other,
"NoopBackend doesn't do anything",
))
Err(io::Error::other("NoopBackend doesn't do anything"))
}
fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
@@ -61,17 +43,11 @@ impl VfsBackend for NoopBackend {
}
fn watch(&mut self, _path: &Path) -> io::Result<()> {
Err(io::Error::new(
io::ErrorKind::Other,
"NoopBackend doesn't do anything",
))
Err(io::Error::other("NoopBackend doesn't do anything"))
}
fn unwatch(&mut self, _path: &Path) -> io::Result<()> {
Err(io::Error::new(
io::ErrorKind::Other,
"NoopBackend doesn't do anything",
))
Err(io::Error::other("NoopBackend doesn't do anything"))
}
}

View File

@@ -109,15 +109,13 @@ impl VfsBackend for StdBackend {
self.watches.insert(path.to_path_buf());
self.watcher
.watch(path, RecursiveMode::Recursive)
.map_err(|inner| io::Error::new(io::ErrorKind::Other, inner))
.map_err(io::Error::other)
}
}
fn unwatch(&mut self, path: &Path) -> io::Result<()> {
self.watches.remove(path);
self.watcher
.unwatch(path)
.map_err(|inner| io::Error::new(io::ErrorKind::Other, inner))
self.watcher.unwatch(path).map_err(io::Error::other)
}
}

View File

@@ -22,6 +22,12 @@ impl RedactionMap {
}
}
/// Returns the numeric ID that was assigned to the provided value,
/// if one exists.
pub fn get_id_for_value(&self, value: impl ToString) -> Option<usize> {
self.ids.get(&value.to_string()).cloned()
}
pub fn intern(&mut self, id: impl ToString) {
let last_id = &mut self.last_id;

View File

@@ -1 +1 @@
7.5.0
7.6.0

View File

@@ -378,13 +378,26 @@ types = {
if pod == "Default" then
return nil
else
return PhysicalProperties.new(
pod.density,
pod.friction,
pod.elasticity,
pod.frictionWeight,
pod.elasticityWeight
)
-- Passing `nil` instead of not passing anything gives
-- different results, so we have to branch here.
if pod.acousticAbsorption then
return (PhysicalProperties.new :: any)(
pod.density,
pod.friction,
pod.elasticity,
pod.frictionWeight,
pod.elasticityWeight,
pod.acousticAbsorption
)
else
return PhysicalProperties.new(
pod.density,
pod.friction,
pod.elasticity,
pod.frictionWeight,
pod.elasticityWeight
)
end
end
end,
@@ -398,6 +411,7 @@ types = {
elasticity = roblox.Elasticity,
frictionWeight = roblox.FrictionWeight,
elasticityWeight = roblox.ElasticityWeight,
acousticAbsorption = roblox.AcousticAbsorption,
}
end
end,

View File

@@ -441,7 +441,8 @@
"friction": 1.0,
"elasticity": 0.0,
"frictionWeight": 50.0,
"elasticityWeight": 25.0
"elasticityWeight": 25.0,
"acousticAbsorption": 0.15625
}
},
"ty": "PhysicalProperties"

File diff suppressed because it is too large Load Diff

View File

@@ -10,6 +10,8 @@ local Version = require(script.Parent.Version)
local validateApiInfo = Types.ifEnabled(Types.ApiInfoResponse)
local validateApiRead = Types.ifEnabled(Types.ApiReadResponse)
local validateApiSubscribe = Types.ifEnabled(Types.ApiSubscribeResponse)
local validateApiSerialize = Types.ifEnabled(Types.ApiSerializeResponse)
local validateApiRefPatch = Types.ifEnabled(Types.ApiRefPatchResponse)
local function rejectFailedRequests(response)
if response.code >= 400 then
@@ -252,4 +254,32 @@ function ApiContext:open(id)
end)
end
function ApiContext:serialize(ids: { string })
local url = ("%s/api/serialize/%s"):format(self.__baseUrl, table.concat(ids, ","))
return Http.get(url):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body)
if body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID")
end
assert(validateApiSerialize(body))
return body
end)
end
function ApiContext:refPatch(ids: { string })
local url = ("%s/api/ref-patch/%s"):format(self.__baseUrl, table.concat(ids, ","))
return Http.get(url):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body)
if body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID")
end
assert(validateApiRefPatch(body))
return body
end)
end
return ApiContext

View File

@@ -9,8 +9,70 @@ local Assets = require(Plugin.Assets)
local Config = require(Plugin.Config)
local Version = require(Plugin.Version)
local Tooltip = require(Plugin.App.Components.Tooltip)
local SlicedImage = require(script.Parent.SlicedImage)
local e = Roact.createElement
local function VersionIndicator(props)
local updateMessage = Version.getUpdateMessage()
return Theme.with(function(theme)
return e("Frame", {
LayoutOrder = props.layoutOrder,
Size = UDim2.new(0, 0, 0, 25),
BackgroundTransparency = 1,
AutomaticSize = Enum.AutomaticSize.X,
}, {
Border = if updateMessage
then e(SlicedImage, {
slice = Assets.Slices.RoundedBorder,
color = theme.Button.Bordered.Enabled.BorderColor,
transparency = props.transparency,
size = UDim2.fromScale(1, 1),
zIndex = 0,
}, {
Indicator = e("ImageLabel", {
Size = UDim2.new(0, 10, 0, 10),
ScaleType = Enum.ScaleType.Fit,
Image = Assets.Images.Circles[16],
ImageColor3 = theme.Header.LogoColor,
ImageTransparency = props.transparency,
BackgroundTransparency = 1,
Position = UDim2.new(1, 0, 0, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
}),
})
else nil,
Tip = if updateMessage
then e(Tooltip.Trigger, {
text = updateMessage,
delay = 0.1,
})
else nil,
VersionText = e("TextLabel", {
Text = Version.display(Config.version),
FontFace = theme.Font.Thin,
TextSize = theme.TextSize.Body,
TextColor3 = theme.Header.VersionColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
BackgroundTransparency = 1,
Size = UDim2.new(0, 0, 1, 0),
AutomaticSize = Enum.AutomaticSize.X,
}, {
Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 6),
PaddingRight = UDim.new(0, 6),
}),
}),
})
end)
end
local function Header(props)
return Theme.with(function(theme)
return e("Frame", {
@@ -29,18 +91,9 @@ local function Header(props)
BackgroundTransparency = 1,
}),
Version = e("TextLabel", {
Text = Version.display(Config.version),
FontFace = theme.Font.Thin,
TextSize = theme.TextSize.Body,
TextColor3 = theme.Header.VersionColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
Size = UDim2.new(1, 0, 0, theme.TextSize.Body),
LayoutOrder = 2,
BackgroundTransparency = 1,
VersionIndicator = e(VersionIndicator, {
transparency = props.transparency,
layoutOrder = 2,
}),
Layout = e("UIListLayout", {

View File

@@ -0,0 +1,151 @@
local StudioService = game:GetService("StudioService")
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Log = require(Packages.Log)
local Theme = require(Plugin.App.Theme)
local Assets = require(Plugin.Assets)
local TextButton = require(Plugin.App.Components.TextButton)
local e = Roact.createElement
local FullscreenNotification = Roact.Component:extend("FullscreeFullscreenNotificationnNotification")
function FullscreenNotification:init()
self.transparency, self.setTransparency = Roact.createBinding(0)
self.lifetime = self.props.timeout
end
function FullscreenNotification:dismiss()
if self.props.onClose then
self.props.onClose()
end
end
function FullscreenNotification:didMount()
self.props.soundPlayer:play(Assets.Sounds.Notification)
self.timeout = task.spawn(function()
local clock = os.clock()
local seen = false
while task.wait(1 / 10) do
local now = os.clock()
local dt = now - clock
clock = now
if not seen then
seen = StudioService.ActiveScript == nil
end
if not seen then
-- Don't run down timer before being viewed
continue
end
self.lifetime -= dt
if self.lifetime <= 0 then
self:dismiss()
break
end
end
self.timeout = nil
end)
end
function FullscreenNotification:willUnmount()
if self.timeout and coroutine.status(self.timeout) ~= "dead" then
task.cancel(self.timeout)
end
end
function FullscreenNotification:render()
return Theme.with(function(theme)
local actionButtons = {}
if self.props.actions then
for key, action in self.props.actions do
actionButtons[key] = e(TextButton, {
text = action.text,
style = action.style,
onClick = function()
self:dismiss()
if action.onClick then
local success, err = pcall(action.onClick, self)
if not success then
Log.warn("Error in notification action: " .. tostring(err))
end
end
end,
layoutOrder = -action.layoutOrder,
transparency = self.transparency,
})
end
end
return e("Frame", {
BackgroundColor3 = theme.BackgroundColor,
Size = UDim2.fromScale(1, 1),
ZIndex = self.props.layoutOrder,
}, {
Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 17),
PaddingRight = UDim.new(0, 15),
PaddingTop = UDim.new(0, 10),
PaddingBottom = UDim.new(0, 10),
}),
Layout = e("UIListLayout", {
SortOrder = Enum.SortOrder.LayoutOrder,
FillDirection = Enum.FillDirection.Vertical,
HorizontalAlignment = Enum.HorizontalAlignment.Center,
VerticalAlignment = Enum.VerticalAlignment.Center,
Padding = UDim.new(0, 10),
}),
Logo = e("ImageLabel", {
ImageTransparency = self.transparency,
Image = Assets.Images.Logo,
ImageColor3 = theme.Header.LogoColor,
BackgroundTransparency = 1,
Size = UDim2.fromOffset(60, 27),
LayoutOrder = 1,
}),
Info = e("TextLabel", {
Text = self.props.text,
FontFace = theme.Font.Main,
TextSize = theme.TextSize.Body,
TextColor3 = theme.Notification.InfoColor,
TextTransparency = self.transparency,
TextXAlignment = Enum.TextXAlignment.Center,
TextYAlignment = Enum.TextYAlignment.Center,
TextWrapped = true,
BackgroundTransparency = 1,
AutomaticSize = Enum.AutomaticSize.Y,
Size = UDim2.fromScale(0.4, 0),
LayoutOrder = 2,
}),
Actions = if self.props.actions
then e("Frame", {
Size = UDim2.new(1, -40, 0, 37),
BackgroundTransparency = 1,
LayoutOrder = 3,
}, {
Layout = e("UIListLayout", {
FillDirection = Enum.FillDirection.Horizontal,
HorizontalAlignment = Enum.HorizontalAlignment.Center,
VerticalAlignment = Enum.VerticalAlignment.Center,
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 5),
}),
Buttons = Roact.createFragment(actionButtons),
})
else nil,
})
end)
end
return FullscreenNotification

View File

@@ -16,8 +16,6 @@ local getTextBoundsAsync = require(Plugin.App.getTextBoundsAsync)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local TextButton = require(Plugin.App.Components.TextButton)
local baseClock = DateTime.now().UnixTimestampMillis
local e = Roact.createElement
local Notification = Roact.Component:extend("Notification")
@@ -77,7 +75,9 @@ function Notification:didMount()
end
function Notification:willUnmount()
task.cancel(self.timeout)
if self.timeout and coroutine.status(self.timeout) ~= "dead" then
task.cancel(self.timeout)
end
end
function Notification:render()
@@ -95,9 +95,12 @@ function Notification:render()
text = action.text,
style = action.style,
onClick = function()
local success, err = pcall(action.onClick, self)
if not success then
Log.warn("Error in notification action: " .. tostring(err))
self:dismiss()
if action.onClick then
local success, err = pcall(action.onClick, self)
if not success then
Log.warn("Error in notification action: " .. tostring(err))
end
end
end,
layoutOrder = -action.layoutOrder,
@@ -138,17 +141,17 @@ function Notification:render()
}, {
e(BorderedContainer, {
transparency = transparency,
size = UDim2.new(1, 0, 1, 0),
size = UDim2.fromScale(1, 1),
}, {
Contents = e("Frame", {
Size = UDim2.new(1, 0, 1, 0),
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
}, {
Logo = e("ImageLabel", {
ImageTransparency = transparency,
Image = Assets.Images.PluginButton,
BackgroundTransparency = 1,
Size = UDim2.new(0, logoSize, 0, logoSize),
Size = UDim2.fromOffset(logoSize, logoSize),
Position = UDim2.new(0, 0, 0, 0),
AnchorPoint = Vector2.new(0, 0),
}),
@@ -171,7 +174,7 @@ function Notification:render()
Actions = if self.props.actions
then e("Frame", {
Size = UDim2.new(1, -40, 0, actionsY),
Position = UDim2.new(1, 0, 1, 0),
Position = UDim2.fromScale(1, 1),
AnchorPoint = Vector2.new(1, 1),
BackgroundTransparency = 1,
}, {
@@ -198,26 +201,4 @@ function Notification:render()
end)
end
local Notifications = Roact.Component:extend("Notifications")
function Notifications:render()
local notifs = {}
for id, notif in self.props.notifications do
notifs["NotifID_" .. id] = e(Notification, {
soundPlayer = self.props.soundPlayer,
text = notif.text,
timestamp = notif.timestamp,
timeout = notif.timeout,
actions = notif.actions,
layoutOrder = (notif.timestamp - baseClock),
onClose = function()
self.props.onClose(id)
end,
})
end
return Roact.createFragment(notifs)
end
return Notifications
return Notification

View File

@@ -0,0 +1,66 @@
local Rojo = script:FindFirstAncestor("Rojo")
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local e = Roact.createElement
local Notification = require(script.Notification)
local FullscreenNotification = require(script.FullscreenNotification)
local Notifications = Roact.Component:extend("Notifications")
function Notifications:render()
local popupNotifs = {}
local fullscreenNotifs = {}
for id, notif in self.props.notifications do
local targetTable = if notif.isFullscreen then fullscreenNotifs else popupNotifs
local targetComponent = if notif.isFullscreen then FullscreenNotification else Notification
targetTable["NotifID_" .. id] = e(targetComponent, {
soundPlayer = self.props.soundPlayer,
text = notif.text,
timeout = notif.timeout,
actions = notif.actions,
layoutOrder = id,
onClose = function()
if notif.onClose then
notif.onClose()
end
self.props.onClose(id)
end,
})
end
return e("Frame", {
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
}, {
Fullscreen = e("Frame", {
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
}, {
notifs = Roact.createFragment(fullscreenNotifs),
}),
Popups = e("Frame", {
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
}, {
Layout = e("UIListLayout", {
SortOrder = Enum.SortOrder.LayoutOrder,
HorizontalAlignment = Enum.HorizontalAlignment.Right,
VerticalAlignment = Enum.VerticalAlignment.Bottom,
Padding = UDim.new(0, 5),
}),
Padding = e("UIPadding", {
PaddingTop = UDim.new(0, 5),
PaddingBottom = UDim.new(0, 5),
PaddingLeft = UDim.new(0, 5),
PaddingRight = UDim.new(0, 5),
}),
notifs = Roact.createFragment(popupNotifs),
}),
})
end
return Notifications

View File

@@ -95,7 +95,7 @@ function DomLabel:render()
return Theme.with(function(theme)
local color = if props.isWarning
then theme.Diff.Warning
elseif props.patchType then theme.Diff[props.patchType]
elseif props.patchType then theme.Diff.Background[props.patchType]
else theme.TextColor
local indent = (depth - 1) * 12 + 15

View File

@@ -178,7 +178,7 @@ function StringDiffVisualizer:render()
size = UDim2.new(1, 0, 1, 0),
position = UDim2.new(0, 0, 0, 0),
text = oldString,
lineBackground = theme.Diff.Remove,
lineBackground = theme.Diff.Background.Remove,
markedLines = self.state.remove,
}),
}),
@@ -193,7 +193,7 @@ function StringDiffVisualizer:render()
size = UDim2.new(1, 0, 1, 0),
position = UDim2.new(0, 0, 0, 0),
text = newString,
lineBackground = theme.Diff.Add,
lineBackground = theme.Diff.Background.Add,
markedLines = self.state.add,
}),
}),

View File

@@ -93,7 +93,7 @@ function Array:render()
e("Frame", {
Size = UDim2.new(1, 0, 0, 25),
BackgroundTransparency = if patchType == "Remain" then 1 else self.props.transparency,
BackgroundColor3 = if patchType == "Remain" then theme.Diff.Row else theme.Diff[patchType],
BackgroundColor3 = theme.Diff.Background[patchType],
BorderSizePixel = 0,
LayoutOrder = i,
}, {

View File

@@ -91,9 +91,7 @@ function Dictionary:render()
LayoutOrder = order,
BorderSizePixel = 0,
BackgroundTransparency = if line.patchType == "Remain" then 1 else self.props.transparency,
BackgroundColor3 = if line.patchType == "Remain"
then theme.Diff.Row
else theme.Diff[line.patchType],
BackgroundColor3 = theme.Diff.Background[line.patchType],
}, {
DiffIcon = if line.patchType ~= "Remain"
then e("ImageLabel", {
@@ -114,7 +112,7 @@ function Dictionary:render()
TextXAlignment = Enum.TextXAlignment.Left,
FontFace = theme.Font.Main,
TextSize = theme.TextSize.Body,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextColor3 = theme.Diff.Text[line.patchType],
TextTruncate = Enum.TextTruncate.AtEnd,
}),
OldValue = e("Frame", {
@@ -125,7 +123,7 @@ function Dictionary:render()
e(DisplayValue, {
value = oldValue,
transparency = self.props.transparency,
textColor = theme.Settings.Setting.DescriptionColor,
textColor = theme.Diff.Text[line.patchType],
}),
}),
NewValue = e("Frame", {
@@ -136,7 +134,7 @@ function Dictionary:render()
e(DisplayValue, {
value = newValue,
transparency = self.props.transparency,
textColor = theme.Settings.Setting.DescriptionColor,
textColor = theme.Diff.Text[line.patchType],
}),
}),
})

View File

@@ -216,7 +216,7 @@ function Trigger:managePopup()
return
end
self.showDelayThread = task.delay(DELAY, function()
self.showDelayThread = task.delay(self.props.delay or DELAY, function()
self.props.context.addTip(self.id, {
Text = self.props.text,
Position = self:getMousePos(),

View File

@@ -8,6 +8,7 @@ local Theme = require(Plugin.App.Theme)
local getTextBoundsAsync = require(Plugin.App.getTextBoundsAsync)
local TextButton = require(Plugin.App.Components.TextButton)
local Header = require(Plugin.App.Components.Header)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame)
local Tooltip = require(Plugin.App.Components.Tooltip)
@@ -38,6 +39,7 @@ function Error:render()
return UDim2.new(1, 0, 0, math.min(outerSize.Y, maximumSize.Y))
end),
transparency = self.props.transparency,
layoutOrder = self.props.layoutOrder,
}, {
ScrollingFrame = e(ScrollingFrame, {
size = UDim2.new(1, 0, 1, 0),
@@ -108,16 +110,21 @@ function ErrorPage:render()
self.setContainerSize(object.AbsoluteSize)
end,
}, {
Error = e(Error, {
errorMessage = self.state.errorMessage,
containerSize = self.containerSize,
Header = e(Header, {
transparency = self.props.transparency,
layoutOrder = 1,
}),
Error = e(Error, {
errorMessage = self.state.errorMessage,
containerSize = self.containerSize,
transparency = self.props.transparency,
layoutOrder = 2,
}),
Buttons = e("Frame", {
Size = UDim2.new(1, 0, 0, 35),
LayoutOrder = 2,
LayoutOrder = 3,
BackgroundTransparency = 1,
}, {
Close = e(TextButton, {

View File

@@ -27,6 +27,7 @@ end
local invertedLevels = invertTbl(Log.Level)
local confirmationBehaviors = { "Initial", "Always", "Large Changes", "Unlisted PlaceId", "Never" }
local syncReminderModes = { "None", "Notify", "Fullscreen" }
local function Navbar(props)
return Theme.with(function(theme)
@@ -93,6 +94,14 @@ function SettingsPage:render()
contentSize = self.contentSize,
transparency = self.props.transparency,
}, {
AutoReconnect = e(Setting, {
id = "autoReconnect",
name = "Auto Reconnect",
description = "Reconnect to server on place open if the served project matches the last sync to the place",
transparency = self.props.transparency,
layoutOrder = layoutIncrement(),
}),
ShowNotifications = e(Setting, {
id = "showNotifications",
name = "Show Notifications",
@@ -101,13 +110,26 @@ function SettingsPage:render()
layoutOrder = layoutIncrement(),
}),
SyncReminder = e(Setting, {
id = "syncReminder",
SyncReminderMode = e(Setting, {
id = "syncReminderMode",
name = "Sync Reminder",
description = "Notify to sync when opening a place that has previously been synced",
description = "What type of reminders you receive for syncing your project",
transparency = self.props.transparency,
visible = Settings:getBinding("showNotifications"),
layoutOrder = layoutIncrement(),
visible = Settings:getBinding("showNotifications"),
options = syncReminderModes,
}),
SyncReminderPolling = e(Setting, {
id = "syncReminderPolling",
name = "Sync Reminder Polling",
description = "Look for available sync servers periodically",
transparency = self.props.transparency,
layoutOrder = layoutIncrement(),
visible = Settings:getBindings("syncReminderMode", "showNotifications"):map(function(values)
return values.syncReminderMode ~= "None" and values.showNotifications
end),
}),
ConfirmationBehavior = e(Setting, {
@@ -159,6 +181,14 @@ function SettingsPage:render()
layoutOrder = layoutIncrement(),
}),
EnableSyncFallback = e(Setting, {
id = "enableSyncFallback",
name = "Enable Sync Fallback",
description = "Whether Instances that fail to sync are remade as a fallback. If this is enabled, Instances may be destroyed and remade when syncing.",
transparency = self.props.transparency,
layoutOrder = layoutIncrement(),
}),
CheckForUpdates = e(Setting, {
id = "checkForUpdates",
name = "Check For Updates",

View File

@@ -165,12 +165,29 @@ function StudioProvider:updateTheme()
BackgroundColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.InputFieldBackground),
},
Diff = {
-- Studio doesn't have good colors since their diffs use backgrounds, not text
Add = if isDark then Color3.fromRGB(143, 227, 154) else Color3.fromRGB(41, 164, 45),
Remove = if isDark then Color3.fromRGB(242, 125, 125) else Color3.fromRGB(150, 29, 29),
Edit = if isDark then Color3.fromRGB(120, 154, 248) else Color3.fromRGB(0, 70, 160),
-- Very bright different colors in case some places were not updated to use
-- the new background diff colors.
Add = Color3.fromRGB(255, 0, 255),
Remove = Color3.fromRGB(255, 0, 255),
Edit = Color3.fromRGB(255, 0, 255),
Row = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
Warning = studioTheme:GetColor(Enum.StudioStyleGuideColor.WarningText),
Background = {
-- Studio doesn't have good colors since their diffs use backgrounds, not text
Add = if isDark then Color3.fromRGB(143, 227, 154) else Color3.fromRGB(41, 164, 45),
Remove = if isDark then Color3.fromRGB(242, 125, 125) else Color3.fromRGB(150, 29, 29),
Edit = if isDark then Color3.fromRGB(120, 154, 248) else Color3.fromRGB(0, 70, 160),
Remain = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),
},
Text = {
Add = if isDark then Color3.new(0, 0, 0) else Color3.new(1, 1, 1),
Remove = if isDark then Color3.new(0, 0, 0) else Color3.new(1, 1, 1),
Edit = if isDark then Color3.new(0, 0, 0) else Color3.new(1, 1, 1),
Remain = studioTheme:GetColor(Enum.StudioStyleGuideColor.MainText),
},
},
ConnectionDetails = {
ProjectNameColor = studioTheme:GetColor(Enum.StudioStyleGuideColor.BrightText),

View File

@@ -9,6 +9,7 @@ local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Log = require(Packages.Log)
local Promise = require(Packages.Promise)
local Assets = require(Plugin.Assets)
local Version = require(Plugin.Version)
@@ -27,7 +28,7 @@ local timeUtil = require(Plugin.timeUtil)
local Theme = require(script.Theme)
local Page = require(script.Page)
local Notifications = require(script.Notifications)
local Notifications = require(script.Components.Notifications)
local Tooltip = require(script.Components.Tooltip)
local StudioPluginAction = require(script.Components.Studio.StudioPluginAction)
local StudioToolbar = require(script.Components.Studio.StudioToolbar)
@@ -78,17 +79,18 @@ function App:init()
action
)
)
local dismissNotif = self:addNotification(
string.format("You've undone '%s'.\nIf this was not intended, please restore.", action),
10,
{
local dismissNotif = self:addNotification({
text = string.format("You've undone '%s'.\nIf this was not intended, please restore.", action),
timeout = 10,
onClose = function()
cleanup()
end,
actions = {
Restore = {
text = "Restore",
style = "Solid",
layoutOrder = 1,
onClick = function(notification)
cleanup()
notification:dismiss()
onClick = function()
ChangeHistoryService:Redo()
end,
},
@@ -96,13 +98,9 @@ function App:init()
text = "Dismiss",
style = "Bordered",
layoutOrder = 2,
onClick = function(notification)
cleanup()
notification:dismiss()
end,
},
}
)
},
})
undoConnection = ChangeHistoryService.OnUndo:Once(function()
-- Our notif is now out of date- redoing will not restore the patch
@@ -142,42 +140,20 @@ function App:init()
if RunService:IsEdit() then
self:checkForUpdates()
if
Settings:get("syncReminder")
and self.serveSession == nil
and self:getPriorSyncInfo().timestamp ~= nil
and (self:isSyncLockAvailable())
then
local syncInfo = self:getPriorSyncInfo()
local timeSinceSync = timeUtil.elapsedToText(os.time() - syncInfo.timestamp)
local syncDetail = if syncInfo.projectName
then `project '{syncInfo.projectName}'`
else `{syncInfo.host or Config.defaultHost}:{syncInfo.port or Config.defaultPort}`
self:startSyncReminderPolling()
self.disconnectSyncReminderPollingChanged = Settings:onChanged("syncReminderPolling", function(enabled)
if enabled then
self:startSyncReminderPolling()
else
self:stopSyncReminderPolling()
end
end)
self:addNotification(
`You synced {syncDetail} to this place {timeSinceSync}. Would you like to reconnect?`,
300,
{
Connect = {
text = "Connect",
style = "Solid",
layoutOrder = 1,
onClick = function(notification)
notification:dismiss()
self:startSession()
end,
},
Dismiss = {
text = "Dismiss",
style = "Bordered",
layoutOrder = 2,
onClick = function(notification)
notification:dismiss()
end,
},
}
)
end
self:tryAutoReconnect():andThen(function(didReconnect)
if not didReconnect then
self:checkSyncReminder()
end
end)
end
if self:isAutoConnectPlaytestServerAvailable() then
@@ -203,16 +179,23 @@ function App:willUnmount()
self.disconnectUpdatesCheckChanged()
self.disconnectPrereleasesCheckChanged()
if self.disconnectSyncReminderPollingChanged then
self.disconnectSyncReminderPollingChanged()
end
self:stopSyncReminderPolling()
self.autoConnectPlaytestServerListener()
self:clearRunningConnectionInfo()
end
function App:addNotification(
function App:addNotification(notif: {
text: string,
isFullscreen: boolean?,
timeout: number?,
actions: { [string]: { text: string, style: string, layoutOrder: number, onClick: (any) -> () } }?
)
actions: { [string]: { text: string, style: string, layoutOrder: number, onClick: (any) -> ()? } }?,
onClose: (any) -> ()?,
})
if not Settings:get("showNotifications") then
return
end
@@ -220,17 +203,17 @@ function App:addNotification(
self.notifId += 1
local id = self.notifId
local notifications = table.clone(self.state.notifications)
notifications[id] = {
text = text,
timestamp = DateTime.now().UnixTimestampMillis,
timeout = timeout or 3,
actions = actions,
}
self:setState(function(prevState)
local notifications = table.clone(prevState.notifications)
notifications[id] = Dictionary.merge({
timeout = notif.timeout or 5,
isFullscreen = notif.isFullscreen or false,
}, notif)
self:setState({
notifications = notifications,
})
return {
notifications = notifications,
}
end)
return function()
self:closeNotification(id)
@@ -242,46 +225,32 @@ function App:closeNotification(id: number)
return
end
local notifications = table.clone(self.state.notifications)
notifications[id] = nil
self:setState(function(prevState)
local notifications = table.clone(prevState.notifications)
notifications[id] = nil
self:setState({
notifications = notifications,
})
return {
notifications = notifications,
}
end)
end
function App:checkForUpdates()
if not Settings:get("checkForUpdates") then
return
end
local updateMessage = Version.getUpdateMessage()
local isLocalInstall = string.find(debug.traceback(), "\n[^\n]-user_.-$") ~= nil
local latestCompatibleVersion = Version.retrieveLatestCompatible({
version = Config.version,
includePrereleases = isLocalInstall and Settings:get("checkForPrereleases"),
})
if not latestCompatibleVersion then
return
end
self:addNotification(
string.format(
"A newer compatible version of Rojo, %s, was published %s! Go to the Rojo releases page to learn more.",
Version.display(latestCompatibleVersion.version),
timeUtil.elapsedToText(DateTime.now().UnixTimestamp - latestCompatibleVersion.publishedUnixTimestamp)
),
500,
{
Dismiss = {
text = "Dismiss",
style = "Bordered",
layoutOrder = 2,
onClick = function(notification)
notification:dismiss()
end,
if updateMessage then
self:addNotification({
text = updateMessage,
timeout = 500,
actions = {
Dismiss = {
text = "Dismiss",
style = "Bordered",
layoutOrder = 2,
},
},
}
)
})
end
end
function App:getPriorSyncInfo(): { host: string?, port: string?, projectName: string?, timestamp: number? }
@@ -402,8 +371,158 @@ function App:releaseSyncLock()
Log.trace("Could not relase sync lock because it is owned by {}", lock.Value)
end
function App:findActiveServer()
local host, port = self:getHostAndPort()
local baseUrl = if string.find(host, "^https?://")
then string.format("%s:%s", host, port)
else string.format("http://%s:%s", host, port)
Log.trace("Checking for active sync server at {}", baseUrl)
local apiContext = ApiContext.new(baseUrl)
return apiContext:connect():andThen(function(serverInfo)
apiContext:disconnect()
return serverInfo, host, port
end)
end
function App:tryAutoReconnect()
if not Settings:get("autoReconnect") then
return Promise.resolve(false)
end
local priorSyncInfo = self:getPriorSyncInfo()
if not priorSyncInfo.projectName then
Log.trace("No prior sync info found, skipping auto-reconnect")
return Promise.resolve(false)
end
return self:findActiveServer()
:andThen(function(serverInfo)
-- change
if serverInfo.projectName == priorSyncInfo.projectName then
Log.trace("Auto-reconnect found matching server, reconnecting...")
self:addNotification({
text = `Auto-reconnect discovered project '{serverInfo.projectName}'...`,
})
self:startSession()
return true
end
Log.trace("Auto-reconnect found different server, not reconnecting")
return false
end)
:catch(function()
Log.trace("Auto-reconnect did not find a server, not reconnecting")
return false
end)
end
function App:checkSyncReminder()
local syncReminderMode = Settings:get("syncReminderMode")
if syncReminderMode == "None" then
return
end
if self.serveSession ~= nil or not self:isSyncLockAvailable() then
-- Already syncing or cannot sync, no reason to remind
return
end
local priorSyncInfo = self:getPriorSyncInfo()
self:findActiveServer()
:andThen(function(serverInfo, host, port)
self:sendSyncReminder(
`Project '{serverInfo.projectName}' is serving at {host}:{port}.\nWould you like to connect?`
)
end)
:catch(function()
if priorSyncInfo.timestamp and priorSyncInfo.projectName then
-- We didn't find an active server,
-- but this place has a prior sync
-- so we should remind the user to serve
local timeSinceSync = timeUtil.elapsedToText(os.time() - priorSyncInfo.timestamp)
self:sendSyncReminder(
`You synced project '{priorSyncInfo.projectName}' to this place {timeSinceSync}.\nDid you mean to run 'rojo serve' and then connect?`
)
end
end)
end
function App:startSyncReminderPolling()
if
self.syncReminderPollingThread ~= nil
or Settings:get("syncReminderMode") == "None"
or not Settings:get("syncReminderPolling")
then
return
end
Log.trace("Starting sync reminder polling thread")
self.syncReminderPollingThread = task.spawn(function()
while task.wait(30) do
if self.syncReminderPollingThread == nil then
-- The polling thread was stopped, so exit
return
end
if self.dismissSyncReminder then
-- There is already a sync reminder being shown
task.wait(5)
continue
end
self:checkSyncReminder()
end
end)
end
function App:stopSyncReminderPolling()
if self.syncReminderPollingThread then
Log.trace("Stopping sync reminder polling thread")
task.cancel(self.syncReminderPollingThread)
self.syncReminderPollingThread = nil
end
end
function App:sendSyncReminder(message: string)
local syncReminderMode = Settings:get("syncReminderMode")
if syncReminderMode == "None" then
return
end
self.dismissSyncReminder = self:addNotification({
text = message,
timeout = 120,
isFullscreen = Settings:get("syncReminderMode") == "Fullscreen",
onClose = function()
self.dismissSyncReminder = nil
end,
actions = {
Connect = {
text = "Connect",
style = "Solid",
layoutOrder = 1,
onClick = function()
self:startSession()
end,
},
Dismiss = {
text = "Dismiss",
style = "Bordered",
layoutOrder = 2,
onClick = function()
-- If the user dismisses the reminder,
-- then we don't need to remind them again
self:stopSyncReminderPolling()
end,
},
},
})
end
function App:isAutoConnectPlaytestServerAvailable()
return RunService:IsRunMode()
return RunService:IsRunning()
and RunService:IsStudio()
and RunService:IsServer()
and Settings:get("autoConnectPlaytestServer")
and workspace:GetAttribute("__Rojo_ConnectionUrl")
@@ -451,7 +570,10 @@ function App:startSession()
local msg = string.format("Could not sync because user '%s' is already syncing", tostring(priorOwner))
Log.warn(msg)
self:addNotification(msg, 10)
self:addNotification({
text = msg,
timeout = 10,
})
self:setState({
appStatus = AppStatus.Error,
errorMessage = msg,
@@ -473,13 +595,13 @@ function App:startSession()
twoWaySync = Settings:get("twoWaySync"),
})
self.cleanupPrecommit = serveSession.__reconciler:hookPrecommit(function(patch, instanceMap)
self.cleanupPrecommit = serveSession:hookPrecommit(function(patch, instanceMap)
-- Build new tree for patch
self:setState({
patchTree = PatchTree.build(patch, instanceMap, { "Property", "Old", "New" }),
})
end)
self.cleanupPostcommit = serveSession.__reconciler:hookPostcommit(function(patch, instanceMap, unappliedPatch)
self.cleanupPostcommit = serveSession:hookPostcommit(function(patch, instanceMap, unappliedPatch)
-- Update tree with unapplied metadata
self:setState(function(prevState)
return {
@@ -522,11 +644,18 @@ function App:startSession()
serveSession:onStatusChanged(function(status, details)
if status == ServeSession.Status.Connecting then
if self.dismissSyncReminder then
self.dismissSyncReminder()
self.dismissSyncReminder = nil
end
self:setState({
appStatus = AppStatus.Connecting,
toolbarIcon = Assets.Images.PluginButton,
})
self:addNotification("Connecting to session...")
self:addNotification({
text = "Connecting to session...",
})
elseif status == ServeSession.Status.Connected then
self.knownProjects[details] = true
self:setPriorSyncInfo(host, port, details)
@@ -539,7 +668,9 @@ function App:startSession()
address = address,
toolbarIcon = Assets.Images.PluginButtonConnected,
})
self:addNotification(string.format("Connected to session '%s' at %s.", details, address), 5)
self:addNotification({
text = string.format("Connected to session '%s' at %s.", details, address),
})
elseif status == ServeSession.Status.Disconnected then
self.serveSession = nil
self:releaseSyncLock()
@@ -562,13 +693,19 @@ function App:startSession()
errorMessage = tostring(details),
toolbarIcon = Assets.Images.PluginButtonWarning,
})
self:addNotification(tostring(details), 10)
self:addNotification({
text = tostring(details),
timeout = 10,
})
else
self:setState({
appStatus = AppStatus.NotConnected,
toolbarIcon = Assets.Images.PluginButton,
})
self:addNotification("Disconnected from session.")
self:addNotification({
text = "Disconnected from session.",
timeout = 10,
})
end
end
end)
@@ -646,13 +783,13 @@ function App:startSession()
toolbarIcon = Assets.Images.PluginButton,
})
self:addNotification(
string.format(
self:addNotification({
text = string.format(
"Please accept%sor abort the initializing sync session.",
Settings:get("twoWaySync") and ", reject, " or " "
),
7
)
timeout = 7,
})
return self.confirmationEvent:Wait()
end)
@@ -813,19 +950,7 @@ function App:render()
ResetOnSpawn = false,
DisplayOrder = 100,
}, {
layout = e("UIListLayout", {
SortOrder = Enum.SortOrder.LayoutOrder,
HorizontalAlignment = Enum.HorizontalAlignment.Right,
VerticalAlignment = Enum.VerticalAlignment.Bottom,
Padding = UDim.new(0, 5),
}),
padding = e("UIPadding", {
PaddingTop = UDim.new(0, 5),
PaddingBottom = UDim.new(0, 5),
PaddingLeft = UDim.new(0, 5),
PaddingRight = UDim.new(0, 5),
}),
notifs = e(Notifications, {
Notifications = e(Notifications, {
soundPlayer = self.props.soundPlayer,
notifications = self.state.notifications,
onClose = function(id)

View File

@@ -282,6 +282,22 @@ function PatchSet.assign(target, ...)
return target
end
function PatchSet.addedIdList(patchSet): { string }
local idList = table.create(#patchSet.added)
for id in patchSet.added do
table.insert(idList, id)
end
return table.freeze(idList)
end
function PatchSet.updatedIdList(patchSet): { string }
local idList = table.create(#patchSet.updated)
for _, item in patchSet.updated do
table.insert(idList, item.id)
end
return table.freeze(idList)
end
--[[
Create a list of human-readable statements summarizing the contents of this
patch, intended to be displayed to users.

View File

@@ -5,8 +5,6 @@
Patches can come from the server or be generated by the client.
]]
local ChangeHistoryService = game:GetService("ChangeHistoryService")
local Packages = script.Parent.Parent.Parent.Packages
local Log = require(Packages.Log)
@@ -20,13 +18,6 @@ local reifyInstance, applyDeferredRefs = reify.reifyInstance, reify.applyDeferre
local setProperty = require(script.Parent.setProperty)
local function applyPatch(instanceMap, patch)
local patchTimestamp = DateTime.now():FormatLocalTime("LTS", "en-us")
local historyRecording = ChangeHistoryService:TryBeginRecording("Rojo: Patch " .. patchTimestamp)
if not historyRecording then
-- There can only be one recording at a time
Log.debug("Failed to begin history recording for " .. patchTimestamp .. ". Another recording is in progress.")
end
-- Tracks any portions of the patch that could not be applied to the DOM.
local unappliedPatch = PatchSet.newEmpty()
@@ -73,9 +64,6 @@ local function applyPatch(instanceMap, patch)
if parentInstance == nil then
-- This would be peculiar. If you create an instance with no
-- parent, were you supposed to create it at all?
if historyRecording then
ChangeHistoryService:FinishRecording(historyRecording, Enum.FinishRecordingOperation.Commit)
end
invariant(
"Cannot add an instance from a patch that has no parent.\nInstance {} with parent {}.\nState: {:#?}",
id,
@@ -244,10 +232,6 @@ local function applyPatch(instanceMap, patch)
end
end
if historyRecording then
ChangeHistoryService:FinishRecording(historyRecording, Enum.FinishRecordingOperation.Commit)
end
applyDeferredRefs(instanceMap, deferredRefs, unappliedPatch)
return unappliedPatch

View File

@@ -25,6 +25,14 @@ local function trueEquals(a, b): boolean
return true
end
-- Treat nil and { Ref = "000...0" } as equal
if
(a == nil and type(b) == "table" and b.Ref == "00000000000000000000000000000000")
or (b == nil and type(a) == "table" and a.Ref == "00000000000000000000000000000000")
then
return true
end
local typeA, typeB = typeof(a), typeof(b)
-- For tables, try recursive deep equality

View File

@@ -5,9 +5,6 @@
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Log = require(Packages.Log)
local Timer = require(Plugin.Timer)
@@ -22,78 +19,17 @@ function Reconciler.new(instanceMap)
local self = {
-- Tracks all of the instances known by the reconciler by ID.
__instanceMap = instanceMap,
__precommitCallbacks = {},
__postcommitCallbacks = {},
}
return setmetatable(self, Reconciler)
end
function Reconciler:hookPrecommit(callback: (patch: any, instanceMap: any) -> ()): () -> ()
table.insert(self.__precommitCallbacks, callback)
Log.trace("Added precommit callback: {}", callback)
return function()
-- Remove the callback from the list
for i, cb in self.__precommitCallbacks do
if cb == callback then
table.remove(self.__precommitCallbacks, i)
Log.trace("Removed precommit callback: {}", callback)
break
end
end
end
end
function Reconciler:hookPostcommit(callback: (patch: any, instanceMap: any, unappliedPatch: any) -> ()): () -> ()
table.insert(self.__postcommitCallbacks, callback)
Log.trace("Added postcommit callback: {}", callback)
return function()
-- Remove the callback from the list
for i, cb in self.__postcommitCallbacks do
if cb == callback then
table.remove(self.__postcommitCallbacks, i)
Log.trace("Removed postcommit callback: {}", callback)
break
end
end
end
end
function Reconciler:applyPatch(patch)
Timer.start("Reconciler:applyPatch")
Timer.start("precommitCallbacks")
-- Precommit callbacks must be serial in order to obey the contract that
-- they execute before commit
for _, callback in self.__precommitCallbacks do
local success, err = pcall(callback, patch, self.__instanceMap)
if not success then
Log.warn("Precommit hook errored: {}", err)
end
end
Timer.stop()
Timer.start("apply")
local unappliedPatch = applyPatch(self.__instanceMap, patch)
Timer.stop()
Timer.start("postcommitCallbacks")
-- Postcommit callbacks can be called with spawn since regardless of firing order, they are
-- guaranteed to be called after the commit
for _, callback in self.__postcommitCallbacks do
task.spawn(function()
local success, err = pcall(callback, patch, self.__instanceMap, unappliedPatch)
if not success then
Log.warn("Postcommit hook errored: {}", err)
end
end)
end
Timer.stop()
Timer.stop()
return unappliedPatch
end

View File

@@ -1,11 +1,15 @@
local StudioService = game:GetService("StudioService")
local RunService = game:GetService("RunService")
local ChangeHistoryService = game:GetService("ChangeHistoryService")
local SerializationService = game:GetService("SerializationService")
local Selection = game:GetService("Selection")
local Packages = script.Parent.Parent.Packages
local Log = require(Packages.Log)
local Fmt = require(Packages.Fmt)
local t = require(Packages.t)
local Promise = require(Packages.Promise)
local Timer = require(script.Parent.Timer)
local ChangeBatcher = require(script.Parent.ChangeBatcher)
local encodePatchUpdate = require(script.Parent.ChangeBatcher.encodePatchUpdate)
@@ -95,6 +99,8 @@ function ServeSession.new(options)
__changeBatcher = changeBatcher,
__statusChangedCallback = nil,
__connections = connections,
__precommitCallbacks = {},
__postcommitCallbacks = {},
}
setmetatable(self, ServeSession)
@@ -125,12 +131,46 @@ function ServeSession:setConfirmCallback(callback)
self.__userConfirmCallback = callback
end
--[=[
Hooks a function to run before patch application.
The provided function is called with the incoming patch and an InstanceMap
as parameters.
]=]
function ServeSession:hookPrecommit(callback)
return self.__reconciler:hookPrecommit(callback)
table.insert(self.__precommitCallbacks, callback)
Log.trace("Added precommit callback: {}", callback)
return function()
-- Remove the callback from the list
for i, cb in self.__precommitCallbacks do
if cb == callback then
table.remove(self.__precommitCallbacks, i)
Log.trace("Removed precommit callback: {}", callback)
break
end
end
end
end
--[=[
Hooks a function to run after patch application.
The provided function is called with the applied patch, the current
InstanceMap, and a PatchSet containing any unapplied changes.
]=]
function ServeSession:hookPostcommit(callback)
return self.__reconciler:hookPostcommit(callback)
table.insert(self.__postcommitCallbacks, callback)
Log.trace("Added postcommit callback: {}", callback)
return function()
-- Remove the callback from the list
for i, cb in self.__postcommitCallbacks do
if cb == callback then
table.remove(self.__postcommitCallbacks, i)
Log.trace("Removed postcommit callback: {}", callback)
break
end
end
end
end
function ServeSession:start()
@@ -139,10 +179,9 @@ function ServeSession:start()
self.__apiContext
:connect()
:andThen(function(serverInfo)
self:__applyGameAndPlaceId(serverInfo)
return self:__initialSync(serverInfo):andThen(function()
self:__setStatus(Status.Connected, serverInfo.projectName)
self:__applyGameAndPlaceId(serverInfo)
return self:__mainSyncLoop()
end)
@@ -207,6 +246,169 @@ function ServeSession:__onActiveScriptChanged(activeScript)
self.__apiContext:open(scriptId)
end
function ServeSession:__replaceInstances(idList)
if #idList == 0 then
return true, PatchSet.newEmpty()
end
-- It would be annoying if selection went away, so we try to preserve it.
local selection = Selection:Get()
local selectionMap = {}
for i, instance in selection do
selectionMap[instance] = i
end
-- TODO: Should we do this in multiple requests so we can more granularly mark failures?
local modelSuccess, replacements = self.__apiContext
:serialize(idList)
:andThen(function(response)
Log.debug("Deserializing results from serialize endpoint")
local objects = SerializationService:DeserializeInstancesAsync(response.modelContents)
if not objects[1] then
return Promise.reject("Serialize endpoint did not deserialize into any Instances")
end
if #objects[1]:GetChildren() ~= #idList then
return Promise.reject("Serialize endpoint did not return the correct number of Instances")
end
local instanceMap = {}
for _, item in objects[1]:GetChildren() do
instanceMap[item.Name] = item.Value
end
return instanceMap
end)
:await()
local refSuccess, refPatch = self.__apiContext
:refPatch(idList)
:andThen(function(response)
return response.patch
end)
:await()
if not (modelSuccess and refSuccess) then
return false
end
for id, replacement in replacements do
local oldInstance = self.__instanceMap.fromIds[id]
self.__instanceMap:insert(id, replacement)
Log.trace("Swapping Instance {} out via api/models/ endpoint", id)
local oldParent = oldInstance.Parent
for _, child in oldInstance:GetChildren() do
child.Parent = replacement
end
replacement.Parent = oldParent
-- ChangeHistoryService doesn't like it if an Instance has been
-- Destroyed. So, we have to accept the potential memory hit and
-- just set the parent to `nil`.
oldInstance.Parent = nil
if selectionMap[oldInstance] then
-- This is a bit funky, but it saves the order of Selection
-- which might matter for some use cases.
selection[selectionMap[oldInstance]] = replacement
end
end
local patchApplySuccess, unappliedPatch = pcall(self.__reconciler.applyPatch, self.__reconciler, refPatch)
if patchApplySuccess then
Selection:Set(selection)
return true, unappliedPatch
else
error(unappliedPatch)
end
end
function ServeSession:__applyPatch(patch)
local patchTimestamp = DateTime.now():FormatLocalTime("LTS", "en-us")
local historyRecording = ChangeHistoryService:TryBeginRecording("Rojo: Patch " .. patchTimestamp)
if not historyRecording then
-- There can only be one recording at a time
Log.debug("Failed to begin history recording for " .. patchTimestamp .. ". Another recording is in progress.")
end
Timer.start("precommitCallbacks")
-- Precommit callbacks must be serial in order to obey the contract that
-- they execute before commit
for _, callback in self.__precommitCallbacks do
local success, err = pcall(callback, patch, self.__instanceMap)
if not success then
Log.warn("Precommit hook errored: {}", err)
end
end
Timer.stop()
local patchApplySuccess, unappliedPatch = pcall(self.__reconciler.applyPatch, self.__reconciler, patch)
if not patchApplySuccess then
if historyRecording then
ChangeHistoryService:FinishRecording(historyRecording, Enum.FinishRecordingOperation.Commit)
end
-- This might make a weird stack trace but the only way applyPatch can
-- fail is if a bug occurs so it's probably fine.
error(unappliedPatch)
end
if PatchSet.isEmpty(unappliedPatch) then
if historyRecording then
ChangeHistoryService:FinishRecording(historyRecording, Enum.FinishRecordingOperation.Commit)
end
return
end
local addedIdList = PatchSet.addedIdList(unappliedPatch)
local updatedIdList = PatchSet.updatedIdList(unappliedPatch)
local actualUnappliedPatches = PatchSet.newEmpty()
if Settings:get("enableSyncFallback") then
Log.debug("ServeSession:__replaceInstances(unappliedPatch.added)")
Timer.start("ServeSession:__replaceInstances(unappliedPatch.added)")
local addSuccess, unappliedAddedRefs = self:__replaceInstances(addedIdList)
Timer.stop()
Log.debug("ServeSession:__replaceInstances(unappliedPatch.updated)")
Timer.start("ServeSession:__replaceInstances(unappliedPatch.updated)")
local updateSuccess, unappliedUpdateRefs = self:__replaceInstances(updatedIdList)
Timer.stop()
if addSuccess then
table.clear(unappliedPatch.added)
PatchSet.assign(actualUnappliedPatches, unappliedAddedRefs)
end
if updateSuccess then
table.clear(unappliedPatch.updated)
PatchSet.assign(actualUnappliedPatches, unappliedUpdateRefs)
end
else
Log.debug("Skipping ServeSession:__replaceInstances because of setting")
end
PatchSet.assign(actualUnappliedPatches, unappliedPatch)
if not PatchSet.isEmpty(actualUnappliedPatches) then
Log.debug(
"Could not apply all changes requested by the Rojo server:\n{}",
PatchSet.humanSummary(self.__instanceMap, unappliedPatch)
)
end
Timer.start("postcommitCallbacks")
-- Postcommit callbacks can be called with spawn since regardless of firing order, they are
-- guaranteed to be called after the commit
for _, callback in self.__postcommitCallbacks do
task.spawn(function()
local success, err = pcall(callback, patch, self.__instanceMap, actualUnappliedPatches)
if not success then
Log.warn("Postcommit hook errored: {}", err)
end
end)
end
Timer.stop()
if historyRecording then
ChangeHistoryService:FinishRecording(historyRecording, Enum.FinishRecordingOperation.Commit)
end
end
function ServeSession:__initialSync(serverInfo)
return self.__apiContext:read({ serverInfo.rootInstanceId }):andThen(function(readResponseBody)
-- Tell the API Context that we're up-to-date with the version of
@@ -281,15 +483,7 @@ function ServeSession:__initialSync(serverInfo)
return self.__apiContext:write(inversePatch)
elseif userDecision == "Accept" then
local unappliedPatch = self.__reconciler:applyPatch(catchUpPatch)
if not PatchSet.isEmpty(unappliedPatch) then
Log.debug(
"Could not apply all changes requested by the Rojo server:\n{}",
PatchSet.humanSummary(self.__instanceMap, unappliedPatch)
)
end
self:__applyPatch(catchUpPatch)
return Promise.resolve()
else
return Promise.reject("Invalid user decision: " .. userDecision)
@@ -312,14 +506,7 @@ function ServeSession:__mainSyncLoop()
Log.trace("Serve session {} retrieved {} messages", tostring(self), #messages)
for _, message in messages do
local unappliedPatch = self.__reconciler:applyPatch(message)
if not PatchSet.isEmpty(unappliedPatch) then
Log.debug(
"Could not apply all changes requested by the Rojo server:\n{}",
PatchSet.humanSummary(self.__instanceMap, unappliedPatch)
)
end
self:__applyPatch(message)
end
end)
:await()

View File

@@ -12,12 +12,15 @@ local Roact = require(Packages.Roact)
local defaultSettings = {
openScriptsExternally = false,
twoWaySync = false,
autoReconnect = false,
showNotifications = true,
syncReminder = true,
enableSyncFallback = true,
syncReminderMode = "Notify" :: "None" | "Notify" | "Fullscreen",
syncReminderPolling = true,
checkForUpdates = true,
checkForPrereleases = false,
autoConnectPlaytestServer = false,
confirmationBehavior = "Initial",
confirmationBehavior = "Initial" :: "Never" | "Initial" | "Large Changes" | "Unlisted PlaceId",
largeChangesConfirmationThreshold = 5,
playSounds = true,
typecheckingEnabled = false,
@@ -108,4 +111,14 @@ function Settings:getBinding(name)
return bind
end
function Settings:getBindings(...: string)
local bindings = {}
for i = 1, select("#", ...) do
local source = select(i, ...)
bindings[source] = self:getBinding(source)
end
return Roact.joinBindings(bindings)
end
return Settings

View File

@@ -55,6 +55,16 @@ local ApiSubscribeResponse = t.interface({
messages = t.array(ApiSubscribeMessage),
})
local ApiSerializeResponse = t.interface({
sessionId = t.string,
modelContents = t.buffer,
})
local ApiRefPatchResponse = t.interface({
sessionId = t.string,
patch = ApiSubscribeMessage,
})
local ApiError = t.interface({
kind = t.union(t.literal("NotFound"), t.literal("BadRequest"), t.literal("InternalError")),
details = t.string,
@@ -82,6 +92,8 @@ return strict("Types", {
ApiInstanceUpdate = ApiInstanceUpdate,
ApiInstanceMetadata = ApiInstanceMetadata,
ApiSubscribeMessage = ApiSubscribeMessage,
ApiSerializeResponse = ApiSerializeResponse,
ApiRefPatchResponse = ApiRefPatchResponse,
ApiValue = ApiValue,
RbxId = RbxId,

View File

@@ -1,6 +1,20 @@
local Packages = script.Parent.Parent.Packages
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Http = require(Packages.Http)
local Promise = require(Packages.Promise)
local Log = require(Packages.Log)
local Config = require(Plugin.Config)
local Settings = require(Plugin.Settings)
local timeUtil = require(Plugin.timeUtil)
type LatestReleaseInfo = {
version: { number },
prerelease: boolean,
publishedUnixTimestamp: number,
}
local function compare(a, b)
if a > b then
@@ -88,14 +102,26 @@ function Version.display(version)
return output
end
--[[
The GitHub API rate limit for unauthenticated requests is rather low,
and we don't release often enough to warrant checking it more than once a day.
--]]
Version._cachedLatestCompatible = nil :: {
value: LatestReleaseInfo?,
timestamp: number,
}?
function Version.retrieveLatestCompatible(options: {
version: { number },
includePrereleases: boolean?,
}): {
version: { number },
prerelease: boolean,
publishedUnixTimestamp: number,
}?
}): LatestReleaseInfo?
if Version._cachedLatestCompatible and os.clock() - Version._cachedLatestCompatible.timestamp < 60 * 60 * 24 then
Log.debug("Using cached latest compatible version")
return Version._cachedLatestCompatible.value
end
Log.debug("Retrieving latest compatible version from GitHub")
local success, releases = Http.get("https://api.github.com/repos/rojo-rbx/rojo/releases?per_page=10")
:andThen(function(response)
if response.code >= 400 then
@@ -114,7 +140,7 @@ function Version.retrieveLatestCompatible(options: {
end
-- Iterate through releases, looking for the latest compatible version
local latestCompatible = nil
local latestCompatible: LatestReleaseInfo? = nil
for _, release in releases do
-- Skip prereleases if they are not requested
if (not options.includePrereleases) and release.prerelease then
@@ -142,10 +168,43 @@ function Version.retrieveLatestCompatible(options: {
-- Don't return anything if the latest found is not newer than the current version
if latestCompatible == nil or Version.compare(latestCompatible.version, options.version) <= 0 then
-- Cache as nil so we don't try again for a day
Version._cachedLatestCompatible = {
value = nil,
timestamp = os.clock(),
}
return nil
end
-- Cache the latest compatible version
Version._cachedLatestCompatible = {
value = latestCompatible,
timestamp = os.clock(),
}
return latestCompatible
end
function Version.getUpdateMessage(): string?
if not Settings:get("checkForUpdates") then
return
end
local isLocalInstall = string.find(debug.traceback(), "\n[^\n]-user_.-$") ~= nil
local latestCompatibleVersion = Version.retrieveLatestCompatible({
version = Config.version,
includePrereleases = isLocalInstall and Settings:get("checkForPrereleases"),
})
if not latestCompatibleVersion then
return
end
return string.format(
"A newer compatible version of Rojo, %s, was published %s! Go to the Rojo releases page to learn more.",
Version.display(latestCompatibleVersion.version),
timeUtil.elapsedToText(DateTime.now().UnixTimestamp - latestCompatibleVersion.publishedUnixTimestamp)
)
end
return Version

View File

@@ -0,0 +1,16 @@
---
source: tests/tests/serve.rs
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
---
instances:
id-2:
Children: []
ClassName: Attachment
Id: id-2
Metadata:
ignoreUnknownInstances: true
Name: forced_parent
Parent: "00000000000000000000000000000000"
Properties: {}
messageCursor: 0
sessionId: id-1

View File

@@ -0,0 +1,13 @@
---
source: tests/tests/serve.rs
expression: redactions.redacted_yaml(&info)
---
expectedPlaceIds: ~
gameId: ~
placeId: ~
projectName: forced_parent
protocolVersion: 4
rootInstanceId: id-2
serverVersion: "[server-version]"
sessionId: id-1
unexpectedPlaceIds: ~

View File

@@ -0,0 +1,27 @@
---
source: tests/tests/serve.rs
expression: model
---
<roblox version="4">
<Item class="Folder" referent="0">
<Properties>
<string name="Name">Folder</string>
</Properties>
<Item class="ObjectValue" referent="1">
<Properties>
<string name="Name">id-2</string>
<Ref name="Value">2</Ref>
</Properties>
<Item class="Part" referent="3">
<Properties>
<string name="Name">Part</string>
</Properties>
<Item class="Attachment" referent="2">
<Properties>
<string name="Name">forced_parent</string>
</Properties>
</Item>
</Item>
</Item>
</Item>
</roblox>

View File

@@ -0,0 +1,58 @@
---
source: tests/tests/serve.rs
expression: "read_response.intern_and_redact(&mut redactions, root_id)"
---
instances:
id-2:
Children:
- id-3
ClassName: DataModel
Id: id-2
Metadata:
ignoreUnknownInstances: true
Name: meshpart
Parent: "00000000000000000000000000000000"
Properties: {}
id-3:
Children:
- id-4
- id-5
ClassName: Workspace
Id: id-3
Metadata:
ignoreUnknownInstances: true
Name: Workspace
Parent: id-2
Properties:
NeedsPivotMigration:
Bool: false
id-4:
Children: []
ClassName: ObjectValue
Id: id-4
Metadata:
ignoreUnknownInstances: true
Name: ObjectValue
Parent: id-3
Properties:
Attributes:
Attributes:
Rojo_Target_Value:
String: sword
Value:
Ref: id-5
id-5:
Children: []
ClassName: MeshPart
Id: id-5
Metadata:
ignoreUnknownInstances: true
Name: Sword
Parent: id-3
Properties:
MeshId:
ContentId: "rbxasset://fonts/sword.mesh"
TextureID:
ContentId: "rbxasset://textures/SwordTexture.png"
messageCursor: 0
sessionId: id-1

View File

@@ -0,0 +1,13 @@
---
source: tests/tests/serve.rs
expression: redactions.redacted_yaml(&info)
---
expectedPlaceIds: ~
gameId: ~
placeId: ~
projectName: meshpart
protocolVersion: 4
rootInstanceId: id-2
serverVersion: "[server-version]"
sessionId: id-1
unexpectedPlaceIds: ~

View File

@@ -0,0 +1,43 @@
---
source: tests/tests/serve.rs
expression: model
---
<roblox version="4">
<Item class="Folder" referent="0">
<Properties>
<string name="Name">Folder</string>
</Properties>
<Item class="ObjectValue" referent="1">
<Properties>
<string name="Name">id-5</string>
<BinaryString name="AttributesSerialize"></BinaryString>
<Ref name="Value">2</Ref>
</Properties>
<Item class="MeshPart" referent="2">
<Properties>
<string name="Name">Sword</string>
<Content name="MeshContent">
<uri>rbxasset://fonts/sword.mesh</uri>
</Content>
<Content name="TextureContent">
<uri>rbxasset://textures/SwordTexture.png</uri>
</Content>
</Properties>
</Item>
</Item>
<Item class="ObjectValue" referent="3">
<Properties>
<string name="Name">id-4</string>
<BinaryString name="AttributesSerialize"></BinaryString>
<Ref name="Value">4</Ref>
</Properties>
<Item class="ObjectValue" referent="4">
<Properties>
<string name="Name">ObjectValue</string>
<BinaryString name="AttributesSerialize">AQAAABEAAABSb2pvX1RhcmdldF9WYWx1ZQIFAAAAc3dvcmQ=</BinaryString>
<Ref name="Value">null</Ref>
</Properties>
</Item>
</Item>
</Item>
</roblox>

View File

@@ -0,0 +1,6 @@
{
"name": "forced_parent",
"tree": {
"$className": "Attachment"
}
}

View File

@@ -0,0 +1,22 @@
{
"name": "meshpart",
"tree": {
"$className": "DataModel",
"Workspace": {
"Sword": {
"$id": "sword",
"$className": "MeshPart",
"$properties": {
"MeshId": "rbxasset://fonts/sword.mesh",
"TextureID": "rbxasset://textures/SwordTexture.png"
}
},
"ObjectValue": {
"$className": "ObjectValue",
"$attributes": {
"Rojo_Target_Value": "sword"
}
}
}
}
}

5
rokit.toml Normal file
View File

@@ -0,0 +1,5 @@
[tools]
rojo = "rojo-rbx/rojo@7.5.1"
selene = "Kampfkarren/selene@0.29.0"
stylua = "JohnnyMorganz/stylua@2.1.0"
run-in-roblox = "rojo-rbx/run-in-roblox@0.3.0"

View File

@@ -2,3 +2,6 @@ std = "roblox+testez"
[config]
unused_variable = { allow_unused_self = true }
[lints]
roblox_manual_fromscale_or_fromoffset = "allow"

View File

@@ -1,45 +1,49 @@
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::str::FromStr;
use std::{
collections::VecDeque,
path::{Path, PathBuf},
};
use std::{
ffi::OsStr,
io::{self, Write},
};
use anyhow::{bail, format_err};
use clap::Parser;
use fs_err as fs;
use fs_err::OpenOptions;
use memofs::{InMemoryFs, Vfs, VfsSnapshot};
use super::resolve_path;
static MODEL_PROJECT: &str =
include_str!("../../assets/default-model-project/default.project.json");
static MODEL_README: &str = include_str!("../../assets/default-model-project/README.md");
static MODEL_INIT: &str = include_str!("../../assets/default-model-project/src-init.luau");
static MODEL_GIT_IGNORE: &str = include_str!("../../assets/default-model-project/gitignore.txt");
const GIT_IGNORE_PLACEHOLDER: &str = "gitignore.txt";
static PLACE_PROJECT: &str =
include_str!("../../assets/default-place-project/default.project.json");
static PLACE_README: &str = include_str!("../../assets/default-place-project/README.md");
static PLACE_GIT_IGNORE: &str = include_str!("../../assets/default-place-project/gitignore.txt");
static PLUGIN_PROJECT: &str =
include_str!("../../assets/default-plugin-project/default.project.json");
static PLUGIN_README: &str = include_str!("../../assets/default-plugin-project/README.md");
static PLUGIN_GIT_IGNORE: &str = include_str!("../../assets/default-plugin-project/gitignore.txt");
static TEMPLATE_BINCODE: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/templates.bincode"));
/// Initializes a new Rojo project.
///
/// By default, this will attempt to initialize a 'git' repository in the
/// project directory if `git` is installed. To avoid this, pass `--skip-git`.
#[derive(Debug, Parser)]
pub struct InitCommand {
/// Path to the place to create the project. Defaults to the current directory.
#[clap(default_value = "")]
pub path: PathBuf,
/// The kind of project to create, 'place', 'plugin', or 'model'. Defaults to place.
/// The kind of project to create, 'place', 'plugin', or 'model'.
#[clap(long, default_value = "place")]
pub kind: InitKind,
/// Skips the initialization of a git repository.
#[clap(long)]
pub skip_git: bool,
}
impl InitCommand {
pub fn run(self) -> anyhow::Result<()> {
let template = self.kind.template();
let base_path = resolve_path(&self.path);
fs::create_dir_all(&base_path)?;
@@ -53,10 +57,51 @@ impl InitCommand {
name: project_name.to_owned(),
};
match self.kind {
InitKind::Place => init_place(&base_path, project_params)?,
InitKind::Model => init_model(&base_path, project_params)?,
InitKind::Plugin => init_plugin(&base_path, project_params)?,
println!(
"Creating new {:?} project '{}'",
self.kind, project_params.name
);
let vfs = Vfs::new(template);
vfs.set_watch_enabled(false);
let mut queue = VecDeque::with_capacity(8);
for entry in vfs.read_dir("")? {
queue.push_back(entry?.path().to_path_buf())
}
while let Some(mut path) = queue.pop_front() {
let metadata = vfs.metadata(&path)?;
if metadata.is_dir() {
fs_err::create_dir(base_path.join(&path))?;
for entry in vfs.read_dir(&path)? {
queue.push_back(entry?.path().to_path_buf());
}
} else {
let content = vfs.read_to_string_lf_normalized(&path)?;
if let Some(file_stem) = path.file_name().and_then(OsStr::to_str) {
if file_stem == GIT_IGNORE_PLACEHOLDER && !self.skip_git {
path.set_file_name(".gitignore");
}
}
write_if_not_exists(
&base_path.join(&path),
&project_params.render_template(&content),
)?;
}
}
if !self.skip_git && should_git_init(&base_path) {
log::debug!("Initializing Git repository...");
let status = Command::new("git")
.arg("init")
.current_dir(&base_path)
.status()?;
if !status.success() {
bail!("git init failed: status code {:?}", status.code());
}
}
println!("Created project successfully.");
@@ -78,6 +123,32 @@ pub enum InitKind {
Plugin,
}
impl InitKind {
fn template(&self) -> InMemoryFs {
let template_path = match self {
Self::Place => "place",
Self::Model => "model",
Self::Plugin => "plugin",
};
let snapshot: VfsSnapshot = bincode::deserialize(TEMPLATE_BINCODE)
.expect("Rojo's templates were not properly packed into Rojo's binary");
if let VfsSnapshot::Dir { mut children } = snapshot {
if let Some(template) = children.remove(template_path) {
let mut fs = InMemoryFs::new();
fs.load_snapshot("", template)
.expect("loading a template in memory should never fail");
fs
} else {
panic!("template for project type {:?} is missing", self)
}
} else {
panic!("Rojo's templates were packed as a file instead of a directory")
}
}
}
impl FromStr for InitKind {
type Err = anyhow::Error;
@@ -94,92 +165,6 @@ impl FromStr for InitKind {
}
}
fn init_place(base_path: &Path, project_params: ProjectParams) -> anyhow::Result<()> {
println!("Creating new place project '{}'", project_params.name);
let project_file = project_params.render_template(PLACE_PROJECT);
try_create_project(base_path, &project_file)?;
let readme = project_params.render_template(PLACE_README);
write_if_not_exists(&base_path.join("README.md"), &readme)?;
let src = base_path.join("src");
fs::create_dir_all(&src)?;
let src_shared = src.join("shared");
fs::create_dir_all(src.join(&src_shared))?;
let src_server = src.join("server");
fs::create_dir_all(src.join(&src_server))?;
let src_client = src.join("client");
fs::create_dir_all(src.join(&src_client))?;
write_if_not_exists(
&src_shared.join("Hello.luau"),
"return function()\n\tprint(\"Hello, world!\")\nend",
)?;
write_if_not_exists(
&src_server.join("init.server.luau"),
"print(\"Hello world, from server!\")",
)?;
write_if_not_exists(
&src_client.join("init.client.luau"),
"print(\"Hello world, from client!\")",
)?;
let git_ignore = project_params.render_template(PLACE_GIT_IGNORE);
try_git_init(base_path, &git_ignore)?;
Ok(())
}
fn init_model(base_path: &Path, project_params: ProjectParams) -> anyhow::Result<()> {
println!("Creating new model project '{}'", project_params.name);
let project_file = project_params.render_template(MODEL_PROJECT);
try_create_project(base_path, &project_file)?;
let readme = project_params.render_template(MODEL_README);
write_if_not_exists(&base_path.join("README.md"), &readme)?;
let src = base_path.join("src");
fs::create_dir_all(&src)?;
let init = project_params.render_template(MODEL_INIT);
write_if_not_exists(&src.join("init.luau"), &init)?;
let git_ignore = project_params.render_template(MODEL_GIT_IGNORE);
try_git_init(base_path, &git_ignore)?;
Ok(())
}
fn init_plugin(base_path: &Path, project_params: ProjectParams) -> anyhow::Result<()> {
println!("Creating new plugin project '{}'", project_params.name);
let project_file = project_params.render_template(PLUGIN_PROJECT);
try_create_project(base_path, &project_file)?;
let readme = project_params.render_template(PLUGIN_README);
write_if_not_exists(&base_path.join("README.md"), &readme)?;
let src = base_path.join("src");
fs::create_dir_all(&src)?;
write_if_not_exists(
&src.join("init.server.luau"),
"print(\"Hello world, from plugin!\")\n",
)?;
let git_ignore = project_params.render_template(PLUGIN_GIT_IGNORE);
try_git_init(base_path, &git_ignore)?;
Ok(())
}
/// Contains parameters used in templates to create a project.
struct ProjectParams {
name: String,
@@ -194,23 +179,6 @@ impl ProjectParams {
}
}
/// Attempt to initialize a Git repository if necessary, and create .gitignore.
fn try_git_init(path: &Path, git_ignore: &str) -> Result<(), anyhow::Error> {
if should_git_init(path) {
log::debug!("Initializing Git repository...");
let status = Command::new("git").arg("init").current_dir(path).status()?;
if !status.success() {
bail!("git init failed: status code {:?}", status.code());
}
}
write_if_not_exists(&path.join(".gitignore"), git_ignore)?;
Ok(())
}
/// Tells whether we should initialize a Git repository inside the given path.
///
/// Will return false if the user doesn't have Git installed or if the path is
@@ -251,29 +219,3 @@ fn write_if_not_exists(path: &Path, contents: &str) -> Result<(), anyhow::Error>
Ok(())
}
/// Try to create a project file and fail if it already exists.
fn try_create_project(base_path: &Path, contents: &str) -> Result<(), anyhow::Error> {
let project_path = base_path.join("default.project.json");
let file_res = OpenOptions::new()
.write(true)
.create_new(true)
.open(&project_path);
let mut file = match file_res {
Ok(file) => file,
Err(err) => {
return match err.kind() {
io::ErrorKind::AlreadyExists => {
bail!("Project file already exists: {}", project_path.display())
}
_ => Err(err.into()),
}
}
};
file.write_all(contents.as_bytes())?;
Ok(())
}

View File

@@ -1,7 +1,8 @@
use std::{
borrow::Cow,
io::{BufWriter, Write},
mem::forget,
path::{Path, PathBuf},
path::{self, Path, PathBuf},
};
use clap::Parser;
@@ -20,6 +21,7 @@ use crate::{
use super::resolve_path;
const PATH_STRIP_FAILED_ERR: &str = "Failed to create relative paths for project file!";
const ABSOLUTE_PATH_FAILED_ERR: &str = "Failed to turn relative path into absolute path!";
/// Representation of a node in the generated sourcemap tree.
#[derive(Serialize)]
@@ -28,8 +30,11 @@ struct SourcemapNode<'a> {
name: &'a str,
class_name: Ustr,
#[serde(skip_serializing_if = "Vec::is_empty")]
file_paths: Vec<PathBuf>,
#[serde(
skip_serializing_if = "Vec::is_empty",
serialize_with = "crate::path_serializer::serialize_vec_absolute"
)]
file_paths: Vec<Cow<'a, Path>>,
#[serde(skip_serializing_if = "Vec::is_empty")]
children: Vec<SourcemapNode<'a>>,
@@ -57,6 +62,10 @@ pub struct SourcemapCommand {
/// Whether to automatically recreate a snapshot when any input files change.
#[clap(long)]
pub watch: bool,
/// Whether the sourcemap should use absolute paths instead of relative paths.
#[clap(long)]
pub absolute: bool,
}
impl SourcemapCommand {
@@ -83,7 +92,7 @@ impl SourcemapCommand {
.build_global()
.unwrap();
write_sourcemap(&session, self.output.as_deref(), filter)?;
write_sourcemap(&session, self.output.as_deref(), filter, self.absolute)?;
if self.watch {
let rt = Runtime::new().unwrap();
@@ -94,7 +103,7 @@ impl SourcemapCommand {
cursor = new_cursor;
if patch_set_affects_sourcemap(&session, &patch_set, filter) {
write_sourcemap(&session, self.output.as_deref(), filter)?;
write_sourcemap(&session, self.output.as_deref(), filter, self.absolute)?;
}
}
}
@@ -160,13 +169,16 @@ fn recurse_create_node<'a>(
referent: Ref,
project_dir: &Path,
filter: fn(&InstanceWithMeta) -> bool,
use_absolute_paths: bool,
) -> Option<SourcemapNode<'a>> {
let instance = tree.get_instance(referent).expect("instance did not exist");
let children: Vec<_> = instance
.children()
.par_iter()
.filter_map(|&child_id| recurse_create_node(tree, child_id, project_dir, filter))
.filter_map(|&child_id| {
recurse_create_node(tree, child_id, project_dir, filter, use_absolute_paths)
})
.collect();
// If this object has no children and doesn't pass the filter, it doesn't
@@ -181,14 +193,30 @@ fn recurse_create_node<'a>(
.iter()
// Not all paths listed as relevant are guaranteed to exist.
.filter(|path| path.is_file())
.map(|path| path.strip_prefix(project_dir).expect(PATH_STRIP_FAILED_ERR))
.map(|path| path.to_path_buf())
.collect();
.map(|path| path.as_path());
let mut output_file_paths: Vec<Cow<'a, Path>> =
Vec::with_capacity(instance.metadata().relevant_paths.len());
if use_absolute_paths {
// It's somewhat important to note here that `path::absolute` takes in a Path and returns a PathBuf
for val in file_paths {
output_file_paths.push(Cow::Owned(
path::absolute(val).expect(ABSOLUTE_PATH_FAILED_ERR),
));
}
} else {
for val in file_paths {
output_file_paths.push(Cow::from(
val.strip_prefix(project_dir).expect(PATH_STRIP_FAILED_ERR),
));
}
};
Some(SourcemapNode {
name: instance.name(),
class_name: instance.class_name(),
file_paths,
file_paths: output_file_paths,
children,
})
}
@@ -197,10 +225,17 @@ fn write_sourcemap(
session: &ServeSession,
output: Option<&Path>,
filter: fn(&InstanceWithMeta) -> bool,
use_absolute_paths: bool,
) -> anyhow::Result<()> {
let tree = session.tree();
let root_node = recurse_create_node(&tree, tree.get_root_id(), session.root_dir(), filter);
let root_node = recurse_create_node(
&tree,
tree.get_root_id(),
session.root_dir(),
filter,
use_absolute_paths,
);
if let Some(output_path) = output {
let mut file = BufWriter::new(File::create(output_path)?);

View File

@@ -1,7 +1,6 @@
use std::path::PathBuf;
use std::str::FromStr;
use anyhow::{bail, format_err, Context};
use anyhow::{bail, Context};
use clap::Parser;
use memofs::Vfs;
use reqwest::{
@@ -91,32 +90,6 @@ impl UploadCommand {
}
}
/// The kind of asset to upload to the website. Affects what endpoints Rojo uses
/// and changes how the asset is built.
#[derive(Debug, Clone, Copy)]
enum UploadKind {
/// Upload to a place.
Place,
/// Upload to a model-like asset, like a Model, Plugin, or Package.
Model,
}
impl FromStr for UploadKind {
type Err = anyhow::Error;
fn from_str(source: &str) -> Result<Self, Self::Err> {
match source {
"place" => Ok(UploadKind::Place),
"model" => Ok(UploadKind::Model),
attempted => Err(format_err!(
"Invalid upload kind '{}'. Valid kinds are: place, model",
attempted
)),
}
}
}
fn do_upload(buffer: Vec<u8>, asset_id: u64, cookie: &str) -> anyhow::Result<()> {
let url = format!(
"https://data.roblox.com/Data/Upload.ashx?assetid={}",

View File

@@ -20,7 +20,10 @@ fn main() {
},
};
log::error!("Rojo crashed!");
log::error!(
"Rojo crashed! You are running Rojo {}.",
env!("CARGO_PKG_VERSION")
);
log::error!("This is probably a Rojo bug.");
log::error!("");
log::error!(

View File

@@ -62,7 +62,7 @@ impl AmbiguousValue {
match &property.data_type {
DataType::Enum(enum_name) => {
let database = rbx_reflection_database::get();
let database = rbx_reflection_database::get().unwrap();
let enum_descriptor = database.enums.get(enum_name).ok_or_else(|| {
format_err!("Unknown enum {}. This is a Rojo bug!", enum_name)
@@ -203,7 +203,7 @@ fn find_descriptor(
class_name: &str,
prop_name: &str,
) -> Option<&'static PropertyDescriptor<'static>> {
let database = rbx_reflection_database::get();
let database = rbx_reflection_database::get().unwrap();
let mut current_class_name = class_name;
loop {

View File

@@ -221,7 +221,7 @@ pub enum InstigatingSource {
ProjectNode(
#[serde(serialize_with = "path_serializer::serialize_absolute")] PathBuf,
String,
ProjectNode,
Box<ProjectNode>,
Option<String>,
),
}

View File

@@ -73,7 +73,7 @@ impl RojoTree {
self.inner.root_ref()
}
pub fn get_instance(&self, id: Ref) -> Option<InstanceWithMeta> {
pub fn get_instance(&self, id: Ref) -> Option<InstanceWithMeta<'_>> {
if let Some(instance) = self.inner.get_by_ref(id) {
let metadata = self.metadata_map.get(&id).unwrap();
@@ -83,7 +83,7 @@ impl RojoTree {
}
}
pub fn get_instance_mut(&mut self, id: Ref) -> Option<InstanceWithMetaMut> {
pub fn get_instance_mut(&mut self, id: Ref) -> Option<InstanceWithMetaMut<'_>> {
if let Some(instance) = self.inner.get_by_ref_mut(id) {
let metadata = self.metadata_map.get_mut(&id).unwrap();

View File

@@ -31,6 +31,7 @@ pub fn snapshot_lua(
script_type: ScriptType,
) -> anyhow::Result<Option<InstanceSnapshot>> {
let run_context_enums = &rbx_reflection_database::get()
.unwrap()
.enums
.get("RunContext")
.expect("Unable to get RunContext enums!")

View File

@@ -17,6 +17,7 @@ mod rbxmx;
mod toml;
mod txt;
mod util;
mod yaml;
use std::{
path::{Path, PathBuf},
@@ -41,6 +42,7 @@ use self::{
rbxmx::snapshot_rbxmx,
toml::snapshot_toml,
txt::snapshot_txt,
yaml::snapshot_yaml,
};
pub use self::{project::snapshot_project_node, util::emit_legacy_scripts_default};
@@ -212,6 +214,7 @@ pub enum Middleware {
Rbxmx,
Toml,
Text,
Yaml,
Ignore,
}
@@ -250,6 +253,7 @@ impl Middleware {
Self::Rbxmx => snapshot_rbxmx(context, vfs, path, name),
Self::Toml => snapshot_toml(context, vfs, path, name),
Self::Text => snapshot_txt(context, vfs, path, name),
Self::Yaml => snapshot_yaml(context, vfs, path, name),
Self::Ignore => Ok(None),
}
}
@@ -315,6 +319,7 @@ pub fn default_sync_rules() -> &'static [SyncRule] {
sync_rule!("*.txt", Text),
sync_rule!("*.rbxmx", Rbxmx),
sync_rule!("*.rbxm", Rbxm),
sync_rule!("*.{yml,yaml}", Yaml),
]
})
}

View File

@@ -289,7 +289,7 @@ pub fn snapshot_project_node(
metadata.instigating_source = Some(InstigatingSource::ProjectNode(
project_path.to_path_buf(),
instance_name.to_string(),
node.clone(),
Box::new(node.clone()),
parent_class.map(|name| name.to_owned()),
));
@@ -313,7 +313,7 @@ fn infer_class_name(name: &str, parent_class: Option<&str>) -> Option<Ustr> {
// Members of DataModel with names that match known services are
// probably supposed to be those services.
let descriptor = rbx_reflection_database::get().classes.get(name)?;
let descriptor = rbx_reflection_database::get().unwrap().classes.get(name)?;
if descriptor.tags.contains(&ClassTag::Service) {
return Some(ustr(name));

View File

@@ -0,0 +1,27 @@
---
source: src/snapshot_middleware/yaml.rs
expression: source
---
return {
string = "this is a string",
boolean = true,
integer = 1337,
float = 123456789.5,
["value-with-hypen"] = "it sure is",
sequence = {"wow", 8675309},
map = {
key = "value",
key2 = "value 2",
key3 = "value 3",
},
["nested-map"] = {{
key = "value",
}, {
key2 = "value 2",
}, {
key3 = "value 3",
}},
whatever_this_is = {"i imagine", "it's", "a", "sequence?"},
null1 = nil,
null2 = nil,
}

View File

@@ -0,0 +1,21 @@
---
source: src/snapshot_middleware/yaml.rs
expression: instance_snapshot
---
snapshot_id: "00000000000000000000000000000000"
metadata:
ignore_unknown_instances: false
instigating_source:
Path: /foo.yaml
relevant_paths:
- /foo.yaml
- /foo.meta.json
context:
emit_legacy_scripts: true
specified_id: ~
name: foo
class_name: ModuleScript
properties:
Source:
String: "return {\n\tstring = \"this is a string\",\n\tboolean = true,\n\tinteger = 1337,\n\tfloat = 123456789.5,\n\t[\"value-with-hypen\"] = \"it sure is\",\n\tsequence = {\"wow\", 8675309},\n\tmap = {\n\t\tkey = \"value\",\n\t\tkey2 = \"value 2\",\n\t\tkey3 = \"value 3\",\n\t},\n\t[\"nested-map\"] = {{\n\t\tkey = \"value\",\n\t}, {\n\t\tkey2 = \"value 2\",\n\t}, {\n\t\tkey3 = \"value 3\",\n\t}},\n\twhatever_this_is = {\"i imagine\", \"it's\", \"a\", \"sequence?\"},\n\tnull1 = nil,\n\tnull2 = nil,\n}"
children: []

View File

@@ -0,0 +1,234 @@
use std::path::Path;
use anyhow::Context as _;
use memofs::{IoResultExt, Vfs};
use rbx_dom_weak::ustr;
use yaml_rust2::{Yaml, YamlLoader};
use crate::{
lua_ast::{Expression, Statement},
snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot},
};
use super::meta_file::AdjacentMetadata;
pub fn snapshot_yaml(
context: &InstanceContext,
vfs: &Vfs,
path: &Path,
name: &str,
) -> anyhow::Result<Option<InstanceSnapshot>> {
let contents = vfs.read_to_string(path)?;
let mut values = YamlLoader::load_from_str(&contents)?;
let value = values
.pop()
.context("all YAML documents must contain a document")?;
if !values.is_empty() {
anyhow::bail!("Rojo does not currently support multiple documents in a YAML file")
}
let as_lua = Statement::Return(yaml_to_luau(value)?);
let meta_path = path.with_file_name(format!("{}.meta.json", name));
let mut snapshot = InstanceSnapshot::new()
.name(name)
.class_name("ModuleScript")
.property(ustr("Source"), as_lua.to_string())
.metadata(
InstanceMetadata::new()
.instigating_source(path)
.relevant_paths(vec![path.to_path_buf(), meta_path.clone()])
.context(context),
);
if let Some(meta_contents) = vfs.read(&meta_path).with_not_found()? {
let mut metadata = AdjacentMetadata::from_slice(&meta_contents, meta_path)?;
metadata.apply_all(&mut snapshot)?;
}
Ok(Some(snapshot))
}
fn yaml_to_luau(value: Yaml) -> anyhow::Result<Expression> {
const MAX_FLOAT_INT: i64 = 1 << 53;
Ok(match value {
Yaml::String(str) => Expression::String(str),
Yaml::Boolean(bool) => Expression::Bool(bool),
Yaml::Integer(int) => {
if int <= MAX_FLOAT_INT {
Expression::Number(int as f64)
} else {
anyhow::bail!(
"the integer '{int}' cannot be losslessly converted into a Luau number"
)
}
}
Yaml::Real(_) => {
let value = value.as_f64().expect("value should be a valid f64");
Expression::Number(value)
}
Yaml::Null => Expression::Nil,
Yaml::Array(values) => {
let new_values: anyhow::Result<Vec<Expression>> =
values.into_iter().map(yaml_to_luau).collect();
Expression::Array(new_values?)
}
Yaml::Hash(map) => {
let new_values: anyhow::Result<Vec<(Expression, Expression)>> = map
.into_iter()
.map(|(k, v)| {
let k = yaml_to_luau(k)?;
let v = yaml_to_luau(v)?;
Ok((k, v))
})
.collect();
Expression::table(new_values?)
}
Yaml::Alias(_) => {
anyhow::bail!("Rojo cannot convert YAML aliases to Luau")
}
Yaml::BadValue => {
anyhow::bail!("Rojo cannot convert YAML to Luau because of a parsing error")
}
})
}
#[cfg(test)]
mod test {
use super::*;
use memofs::{InMemoryFs, VfsSnapshot};
use rbx_dom_weak::types::Variant;
#[test]
fn instance_from_vfs() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
"/foo.yaml",
VfsSnapshot::file(
r#"
---
string: this is a string
boolean: true
integer: 1337
float: 123456789.5
value-with-hypen: it sure is
sequence:
- wow
- 8675309
map:
key: value
key2: "value 2"
key3: 'value 3'
nested-map:
- key: value
- key2: "value 2"
- key3: 'value 3'
whatever_this_is: [i imagine, it's, a, sequence?]
null1: ~
null2: null"#,
),
)
.unwrap();
let vfs = Vfs::new(imfs.clone());
let instance_snapshot = snapshot_yaml(
&InstanceContext::default(),
&vfs,
Path::new("/foo.yaml"),
"foo",
)
.unwrap()
.unwrap();
insta::assert_yaml_snapshot!(instance_snapshot);
let source = instance_snapshot
.properties
.get(&ustr("Source"))
.expect("the result from snapshot_yaml should have a Source property");
if let Variant::String(source) = source {
insta::assert_snapshot!(source)
} else {
panic!("the Source property from snapshot_yaml was not a String")
}
}
#[test]
#[should_panic(expected = "multiple documents")]
fn multiple_documents() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
"/foo.yaml",
VfsSnapshot::file(
r#"
---
document-1: this is a document
---
document-2: this is also a document"#,
),
)
.unwrap();
let vfs = Vfs::new(imfs.clone());
snapshot_yaml(
&InstanceContext::default(),
&vfs,
Path::new("/foo.yaml"),
"foo",
)
.unwrap()
.unwrap();
}
#[test]
#[should_panic = "cannot be losslessly converted into a Luau number"]
fn integer_border() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
"/allowed.yaml",
VfsSnapshot::file(
r#"
value: 9007199254740992
"#,
),
)
.unwrap();
imfs.load_snapshot(
"/not-allowed.yaml",
VfsSnapshot::file(
r#"
value: 9007199254740993
"#,
),
)
.unwrap();
let vfs = Vfs::new(imfs.clone());
assert!(
snapshot_yaml(
&InstanceContext::default(),
&vfs,
Path::new("/allowed.yaml"),
"allowed",
)
.is_ok(),
"snapshot_yaml failed to snapshot document with integer '9007199254740992' in it"
);
snapshot_yaml(
&InstanceContext::default(),
&vfs,
Path::new("/not-allowed.yaml"),
"not-allowed",
)
.unwrap()
.unwrap();
}
}

View File

@@ -1,11 +1,20 @@
//! Defines Rojo's HTTP API, all under /api. These endpoints generally return
//! JSON.
use std::{collections::HashMap, fs, path::PathBuf, str::FromStr, sync::Arc};
use std::{
collections::{HashMap, HashSet},
fs,
path::PathBuf,
str::FromStr,
sync::Arc,
};
use hyper::{body, Body, Method, Request, Response, StatusCode};
use opener::OpenError;
use rbx_dom_weak::types::Ref;
use rbx_dom_weak::{
types::{Ref, Variant},
InstanceBuilder, UstrMap, WeakDom,
};
use crate::{
serve_session::ServeSession,
@@ -18,6 +27,7 @@ use crate::{
},
util::{json, json_ok},
},
web_api::{BufferEncode, InstanceUpdate, RefPatchResponse, SerializeResponse},
};
pub async fn call(serve_session: Arc<ServeSession>, request: Request<Body>) -> Response<Body> {
@@ -31,10 +41,16 @@ pub async fn call(serve_session: Arc<ServeSession>, request: Request<Body>) -> R
(&Method::GET, path) if path.starts_with("/api/subscribe/") => {
service.handle_api_subscribe(request).await
}
(&Method::GET, path) if path.starts_with("/api/serialize/") => {
service.handle_api_serialize(request).await
}
(&Method::GET, path) if path.starts_with("/api/ref-patch/") => {
service.handle_api_ref_patch(request).await
}
(&Method::POST, path) if path.starts_with("/api/open/") => {
service.handle_api_open(request).await
}
(&Method::POST, "/api/write") => service.handle_api_write(request).await,
(_method, path) => json(
@@ -201,6 +217,126 @@ impl ApiService {
})
}
/// Accepts a list of IDs and returns them serialized as a binary model.
/// The model is sent in a schema that causes Roblox to deserialize it as
/// a Luau `buffer`.
///
/// The returned model is a folder that contains ObjectValues with names
/// that correspond to the requested Instances. These values have their
/// `Value` property set to point to the requested Instance.
async fn handle_api_serialize(&self, request: Request<Body>) -> Response<Body> {
let argument = &request.uri().path()["/api/serialize/".len()..];
let requested_ids: Result<Vec<Ref>, _> = argument.split(',').map(Ref::from_str).collect();
let requested_ids = match requested_ids {
Ok(ids) => ids,
Err(_) => {
return json(
ErrorResponse::bad_request("Malformed ID list"),
StatusCode::BAD_REQUEST,
);
}
};
let mut response_dom = WeakDom::new(InstanceBuilder::new("Folder"));
let tree = self.serve_session.tree();
for id in &requested_ids {
if let Some(instance) = tree.get_instance(*id) {
let clone = response_dom.insert(
Ref::none(),
InstanceBuilder::new(instance.class_name())
.with_name(instance.name())
.with_properties(instance.properties().clone()),
);
let object_value = response_dom.insert(
response_dom.root_ref(),
InstanceBuilder::new("ObjectValue")
.with_name(id.to_string())
.with_property("Value", clone),
);
let mut child_ref = clone;
if let Some(parent_class) = parent_requirements(&instance.class_name()) {
child_ref =
response_dom.insert(object_value, InstanceBuilder::new(parent_class));
response_dom.transfer_within(clone, child_ref);
}
response_dom.transfer_within(child_ref, object_value);
} else {
json(
ErrorResponse::bad_request(format!("provided id {id} is not in the tree")),
StatusCode::BAD_REQUEST,
);
}
}
drop(tree);
let mut source = Vec::new();
rbx_binary::to_writer(&mut source, &response_dom, &[response_dom.root_ref()]).unwrap();
json_ok(SerializeResponse {
session_id: self.serve_session.session_id(),
model_contents: BufferEncode::new(source),
})
}
/// Returns a list of all referent properties that point towards the
/// provided IDs. Used because the plugin does not store a RojoTree,
/// and referent properties need to be updated after the serialize
/// endpoint is used.
async fn handle_api_ref_patch(self, request: Request<Body>) -> Response<Body> {
let argument = &request.uri().path()["/api/ref-patch/".len()..];
let requested_ids: Result<HashSet<Ref>, _> =
argument.split(',').map(Ref::from_str).collect();
let requested_ids = match requested_ids {
Ok(ids) => ids,
Err(_) => {
return json(
ErrorResponse::bad_request("Malformed ID list"),
StatusCode::BAD_REQUEST,
);
}
};
let mut instance_updates: HashMap<Ref, InstanceUpdate> = HashMap::new();
let tree = self.serve_session.tree();
for instance in tree.descendants(tree.get_root_id()) {
for (prop_name, prop_value) in instance.properties() {
let Variant::Ref(prop_value) = prop_value else {
continue;
};
if let Some(target_id) = requested_ids.get(prop_value) {
let instance_id = instance.id();
let update =
instance_updates
.entry(instance_id)
.or_insert_with(|| InstanceUpdate {
id: instance_id,
changed_class_name: None,
changed_name: None,
changed_metadata: None,
changed_properties: UstrMap::default(),
});
update
.changed_properties
.insert(*prop_name, Some(Variant::Ref(*target_id)));
}
}
}
json_ok(RefPatchResponse {
session_id: self.serve_session.session_id(),
patch: SubscribeMessage {
added: HashMap::new(),
removed: Vec::new(),
updated: instance_updates.into_values().collect(),
},
})
}
/// Open a script with the given ID in the user's default text editor.
async fn handle_api_open(&self, request: Request<Body>) -> Response<Body> {
let argument = &request.uri().path()["/api/open/".len()..];
@@ -306,3 +442,17 @@ fn pick_script_path(instance: InstanceWithMeta<'_>) -> Option<PathBuf> {
})
.map(|path| path.to_owned())
}
/// Certain Instances MUST be a child of specific classes. This function
/// tracks that information for the Serialize endpoint.
///
/// If a parent requirement exists, it will be returned.
/// Otherwise returns `None`.
fn parent_requirements(class: &str) -> Option<&str> {
Some(match class {
"Attachment" | "Bone" => "Part",
"Animator" => "Humanoid",
"BaseWrap" | "WrapLayer" | "WrapTarget" | "WrapDeformer" => "MeshPart",
_ => return None,
})
}

View File

@@ -208,6 +208,44 @@ pub struct OpenResponse {
pub session_id: SessionId,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SerializeResponse {
pub session_id: SessionId,
pub model_contents: BufferEncode,
}
/// Using this struct we can force Roblox to JSONDecode this as a buffer.
/// This is what Roblox's serde APIs use, so it saves a step in the plugin.
#[derive(Debug, Serialize, Deserialize)]
pub struct BufferEncode {
m: (),
t: Cow<'static, str>,
base64: String,
}
impl BufferEncode {
pub fn new(content: Vec<u8>) -> Self {
let base64 = data_encoding::BASE64.encode(&content);
Self {
m: (),
t: Cow::Borrowed("buffer"),
base64,
}
}
pub fn model(&self) -> &str {
&self.base64
}
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RefPatchResponse<'a> {
pub session_id: SessionId,
pub patch: SubscribeMessage<'a>,
}
/// General response type returned from all Rojo routes
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]

View File

@@ -1,4 +1,5 @@
use std::{
fmt::Write as _,
fs,
path::{Path, PathBuf},
process::Command,
@@ -11,7 +12,7 @@ use rbx_dom_weak::types::Ref;
use tempfile::{tempdir, TempDir};
use librojo::web_api::{ReadResponse, ServerInfoResponse, SubscribeResponse};
use librojo::web_api::{ReadResponse, SerializeResponse, ServerInfoResponse, SubscribeResponse};
use rojo_insta_ext::RedactionMap;
use crate::rojo_test::io_util::{
@@ -159,7 +160,7 @@ impl TestServeSession {
Ok(serde_json::from_str(&body).expect("Server returned malformed response"))
}
pub fn get_api_read(&self, id: Ref) -> Result<ReadResponse, reqwest::Error> {
pub fn get_api_read(&self, id: Ref) -> Result<ReadResponse<'_>, reqwest::Error> {
let url = format!("http://localhost:{}/api/read/{}", self.port, id);
let body = reqwest::blocking::get(url)?.text()?;
@@ -174,6 +175,18 @@ impl TestServeSession {
reqwest::blocking::get(url)?.json()
}
pub fn get_api_serialize(&self, ids: &[Ref]) -> Result<SerializeResponse, reqwest::Error> {
let mut id_list = String::with_capacity(ids.len() * 33);
for id in ids {
write!(id_list, "{id},").unwrap();
}
id_list.pop();
let url = format!("http://localhost:{}/api/serialize/{}", self.port, id_list);
reqwest::blocking::get(url)?.json()
}
}
/// Probably-okay way to generate random enough port numbers for running the
@@ -187,3 +200,27 @@ fn get_port_number() -> usize {
NEXT_PORT_NUMBER.fetch_add(1, Ordering::SeqCst)
}
/// Takes a SerializeResponse and creates an XML model out of the response.
///
/// Since the provided structure intentionally includes unredacted referents,
/// some post-processing is done to ensure they don't show up in the model.
pub fn serialize_to_xml_model(response: &SerializeResponse, redactions: &RedactionMap) -> String {
let model_content = data_encoding::BASE64
.decode(response.model_contents.model().as_bytes())
.unwrap();
let mut dom = rbx_binary::from_reader(model_content.as_slice()).unwrap();
// This makes me realize that maybe we need a `descendants_mut` iter.
let ref_list: Vec<Ref> = dom.descendants().map(|inst| inst.referent()).collect();
for referent in ref_list {
let inst = dom.get_by_ref_mut(referent).unwrap();
if let Some(id) = redactions.get_id_for_value(&inst.name) {
inst.name = format!("id-{id}");
}
}
let mut data = Vec::new();
rbx_xml::to_writer_default(&mut data, &dom, dom.root().children()).unwrap();
String::from_utf8(data).expect("rbx_xml should never produce invalid utf-8")
}

View File

@@ -1,9 +1,12 @@
use std::fs;
use insta::{assert_yaml_snapshot, with_settings};
use insta::{assert_snapshot, assert_yaml_snapshot, with_settings};
use tempfile::tempdir;
use crate::rojo_test::{internable::InternAndRedact, serve_util::run_serve_test};
use crate::rojo_test::{
internable::InternAndRedact,
serve_util::{run_serve_test, serialize_to_xml_model},
};
#[test]
fn empty() {
@@ -591,3 +594,66 @@ fn model_pivot_migration() {
);
});
}
#[test]
fn meshpart_with_id() {
run_serve_test("meshpart_with_id", |session, mut redactions| {
let info = session.get_api_rojo().unwrap();
let root_id = info.root_instance_id;
assert_yaml_snapshot!("meshpart_with_id_info", redactions.redacted_yaml(&info));
let read_response = session.get_api_read(root_id).unwrap();
assert_yaml_snapshot!(
"meshpart_with_id_all",
read_response.intern_and_redact(&mut redactions, root_id)
);
// This is a bit awkward, but it's fine.
let (meshpart, _) = read_response
.instances
.iter()
.find(|(_, inst)| inst.class_name == "MeshPart")
.unwrap();
let (objectvalue, _) = read_response
.instances
.iter()
.find(|(_, inst)| inst.class_name == "ObjectValue")
.unwrap();
let serialize_response = session
.get_api_serialize(&[*meshpart, *objectvalue])
.unwrap();
// We don't assert a snapshot on the SerializeResponse because the model includes the
// Refs from the DOM as names, which means it will obviously be different every time
// this code runs. Still, we ensure that the SessionId is right at least.
assert_eq!(serialize_response.session_id, info.session_id);
let model = serialize_to_xml_model(&serialize_response, &redactions);
assert_snapshot!("meshpart_with_id_serialize_model", model);
});
}
#[test]
fn forced_parent() {
run_serve_test("forced_parent", |session, mut redactions| {
let info = session.get_api_rojo().unwrap();
let root_id = info.root_instance_id;
assert_yaml_snapshot!("forced_parent_info", redactions.redacted_yaml(&info));
let read_response = session.get_api_read(root_id).unwrap();
assert_yaml_snapshot!(
"forced_parent_all",
read_response.intern_and_redact(&mut redactions, root_id)
);
let serialize_response = session.get_api_serialize(&[root_id]).unwrap();
assert_eq!(serialize_response.session_id, info.session_id);
let model = serialize_to_xml_model(&serialize_response, &redactions);
assert_snapshot!("forced_parent_serialize_model", model);
});
}