Compare commits

...

30 Commits

Author SHA1 Message Date
Micah
825726c883 Release 7.6.1 (#1151) 2025-11-06 18:49:05 -08:00
boatbomber
54e63d88d4 Slightly improve initial sync hangs (#1140) 2025-11-06 00:06:42 -08:00
boatbomber
4018c97cb6 Make CHANGELOG.md use consistent style (#1146) 2025-10-28 19:26:48 -07:00
boatbomber
d0b029f995 Add JSONC Support for Project, Meta, and Model JSON files (#1144)
Replaces `serde_json` parsing with `jsonc-parser` throughout the
codebase, enabling support for **comments** and **trailing commas** in
all JSON files including `.project.json`, `.model.json`, and
`.meta.json` files.
MSRV bumps from `1.83.0` to `1.88.0` in order to
use the jsonc_parser dependency.
2025-10-28 17:29:57 -07:00
Sebastian Stachowicz
aabe6d11b2 Update default gitignores to include sourcemap (#1145) 2025-10-28 17:28:55 -07:00
boatbomber
181cc37744 Improve sync fallback robustness (#1135) 2025-10-20 20:13:47 -07:00
boatbomber
cd78f5c02c Fix postcommit callbacks being skipped (#1132) 2025-10-14 12:13:59 -07:00
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
93 changed files with 11232 additions and 1789 deletions

View File

@@ -16,7 +16,7 @@ jobs:
strategy: strategy:
matrix: matrix:
os: [ubuntu-latest, windows-latest, macos-latest] os: [ubuntu-22.04, windows-latest, macos-latest, windows-11-arm, ubuntu-22.04-arm]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
@@ -26,13 +26,14 @@ jobs:
- name: Install Rust - name: Install Rust
uses: dtolnay/rust-toolchain@stable uses: dtolnay/rust-toolchain@stable
- name: Rust cache - name: Restore Rust Cache
uses: Swatinem/rust-cache@v2 uses: actions/cache/restore@v4
- name: Setup Aftman
uses: ok-nick/setup-aftman@v0.4.2
with: with:
version: 'v0.3.0' path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ matrix.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Build - name: Build
run: cargo build --locked --verbose run: cargo build --locked --verbose
@@ -40,6 +41,15 @@ jobs:
- name: Test - name: Test
run: cargo test --locked --verbose 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: msrv:
name: Check MSRV name: Check MSRV
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -50,19 +60,29 @@ jobs:
submodules: true submodules: true
- name: Install Rust - name: Install Rust
uses: dtolnay/rust-toolchain@1.70.0 uses: dtolnay/rust-toolchain@1.88.0
- name: Rust cache - name: Restore Rust Cache
uses: Swatinem/rust-cache@v2 uses: actions/cache/restore@v4
- name: Setup Aftman
uses: ok-nick/setup-aftman@v0.4.2
with: with:
version: 'v0.3.0' path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Build - name: Build
run: cargo build --locked --verbose 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: lint:
name: Rustfmt, Clippy, Stylua, & Selene name: Rustfmt, Clippy, Stylua, & Selene
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -77,13 +97,19 @@ jobs:
with: with:
components: rustfmt, clippy components: rustfmt, clippy
- name: Rust cache - name: Restore Rust Cache
uses: Swatinem/rust-cache@v2 uses: actions/cache/restore@v4
- name: Setup Aftman
uses: ok-nick/setup-aftman@v0.4.2
with: 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 - name: Stylua
run: stylua --check plugin/src run: stylua --check plugin/src
@@ -97,3 +123,11 @@ jobs:
- name: Clippy - name: Clippy
run: cargo 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: with:
submodules: true submodules: true
- name: Setup Aftman - name: Setup Rokit
uses: ok-nick/setup-aftman@v0.4.2 uses: CompeyDev/setup-rokit@v0.1.2
with: with:
version: 'v0.3.0' version: 'v1.1.0'
- name: Build Plugin - name: Build Plugin
run: rojo build plugin.project.json --output Rojo.rbxm run: rojo build plugin.project.json --output Rojo.rbxm
@@ -53,15 +53,25 @@ jobs:
# https://doc.rust-lang.org/rustc/platform-support.html # https://doc.rust-lang.org/rustc/platform-support.html
include: include:
- host: linux - host: linux
os: ubuntu-latest os: ubuntu-22.04
target: x86_64-unknown-linux-gnu target: x86_64-unknown-linux-gnu
label: linux-x86_64 label: linux-x86_64
- host: linux
os: ubuntu-22.04-arm
target: aarch64-unknown-linux-gnu
label: linux-aarch64
- host: windows - host: windows
os: windows-latest os: windows-latest
target: x86_64-pc-windows-msvc target: x86_64-pc-windows-msvc
label: windows-x86_64 label: windows-x86_64
- host: windows
os: windows-11-arm
target: aarch64-pc-windows-msvc
label: windows-aarch64
- host: macos - host: macos
os: macos-latest os: macos-latest
target: x86_64-apple-darwin target: x86_64-apple-darwin
@@ -86,17 +96,26 @@ jobs:
with: with:
targets: ${{ matrix.target }} targets: ${{ matrix.target }}
- name: Setup Aftman - name: Restore Rust Cache
uses: ok-nick/setup-aftman@v0.4.2 uses: actions/cache/restore@v4
with: with:
version: 'v0.3.0' path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ matrix.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Build Release - name: Build Release
run: cargo build --release --locked --verbose --target ${{ matrix.target }} run: cargo build --release --locked --verbose --target ${{ matrix.target }}
env:
# Build into a known directory so we can find our build artifact more - name: Save Rust Cache
# easily. uses: actions/cache/save@v4
CARGO_TARGET_DIR: output with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: ${{ matrix.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
- name: Generate Artifact Name - name: Generate Artifact Name
shell: bash shell: bash
@@ -113,11 +132,11 @@ jobs:
mkdir staging mkdir staging
if [ "${{ matrix.host }}" = "windows" ]; then if [ "${{ matrix.host }}" = "windows" ]; then
cp "output/${{ matrix.target }}/release/$BIN.exe" staging/ cp "target/${{ matrix.target }}/release/$BIN.exe" staging/
cd staging cd staging
7z a ../$ARTIFACT_NAME * 7z a ../$ARTIFACT_NAME *
else else
cp "output/${{ matrix.target }}/release/$BIN" staging/ cp "target/${{ matrix.target }}/release/$BIN" staging/
cd staging cd staging
zip ../$ARTIFACT_NAME * zip ../$ARTIFACT_NAME *
fi fi

File diff suppressed because it is too large Load Diff

View File

@@ -15,7 +15,7 @@ You'll want these tools to work on Rojo:
* Latest stable Rust compiler * Latest stable Rust compiler
* Latest stable [Rojo](https://github.com/rojo-rbx/rojo) * 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.) * [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: 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
Documentation impacts way more people than the individual lines of code we write. 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 ## 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. 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.

228
Cargo.lock generated
View File

@@ -1,6 +1,6 @@
# This file is automatically @generated by Cargo. # This file is automatically @generated by Cargo.
# It is not intended for manual editing. # It is not intended for manual editing.
version = 3 version = 4
[[package]] [[package]]
name = "addr2line" name = "addr2line"
@@ -45,6 +45,12 @@ version = "1.0.80"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ad32ce52e4161730f7098c077cd2ed6229b5804ccf99e5366be1ab72a98b4e1" checksum = "5ad32ce52e4161730f7098c077cd2ed6229b5804ccf99e5366be1ab72a98b4e1"
[[package]]
name = "arraydeque"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
[[package]] [[package]]
name = "arrayref" name = "arrayref"
version = "0.3.7" version = "0.3.7"
@@ -237,7 +243,7 @@ checksum = "ae6371b8bdc8b7d3959e9cf7b22d4435ef3e79e138688421ec654acf8c81b008"
dependencies = [ dependencies = [
"heck", "heck",
"proc-macro-error", "proc-macro-error",
"proc-macro2 1.0.78", "proc-macro2 1.0.103",
"quote 1.0.35", "quote 1.0.35",
"syn 1.0.109", "syn 1.0.109",
] ]
@@ -395,6 +401,12 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "data-encoding"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
[[package]] [[package]]
name = "diff" name = "diff"
version = "0.1.13" version = "0.1.13"
@@ -418,7 +430,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13aea89a5c93364a98e9b37b2fa237effbb694d5cfe01c5b70941f7eb087d5e3" checksum = "13aea89a5c93364a98e9b37b2fa237effbb694d5cfe01c5b70941f7eb087d5e3"
dependencies = [ dependencies = [
"cfg-if 0.1.10", "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]] [[package]]
@@ -432,6 +453,18 @@ dependencies = [
"winapi 0.3.9", "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]] [[package]]
name = "either" name = "either"
version = "1.10.0" version = "1.10.0"
@@ -519,6 +552,12 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.1" version = "1.2.1"
@@ -626,9 +665,9 @@ version = "0.3.30"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac"
dependencies = [ dependencies = [
"proc-macro2 1.0.78", "proc-macro2 1.0.103",
"quote 1.0.35", "quote 1.0.35",
"syn 2.0.52", "syn 2.0.108",
] ]
[[package]] [[package]]
@@ -751,6 +790,24 @@ version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" 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]] [[package]]
name = "heck" name = "heck"
version = "0.4.1" version = "0.4.1"
@@ -976,6 +1033,15 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "jsonc-parser"
version = "0.27.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ec4ac49f13c7b00f435f8a5bb55d725705e2cf620df35a5859321595102eb7e"
dependencies = [
"serde_json",
]
[[package]] [[package]]
name = "kernel32-sys" name = "kernel32-sys"
version = "0.2.2" version = "0.2.2"
@@ -1057,23 +1123,12 @@ dependencies = [
] ]
[[package]] [[package]]
name = "lz4" name = "lz4_flex"
version = "1.24.0" version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e9e2dd86df36ce760a60f6ff6ad526f7ba1f14ba0356f8254fb6905e6494df1" checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a"
dependencies = [ dependencies = [
"libc", "twox-hash",
"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",
] ]
[[package]] [[package]]
@@ -1265,6 +1320,12 @@ dependencies = [
"winapi 0.3.9", "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]] [[package]]
name = "os_str_bytes" name = "os_str_bytes"
version = "6.6.1" version = "6.6.1"
@@ -1341,9 +1402,9 @@ checksum = "fdc17e2a6c7d0a492f0158d7a4bd66cc17280308bbaff78d5bef566dca35ab80"
dependencies = [ dependencies = [
"pest", "pest",
"pest_meta", "pest_meta",
"proc-macro2 1.0.78", "proc-macro2 1.0.103",
"quote 1.0.35", "quote 1.0.35",
"syn 2.0.52", "syn 2.0.108",
] ]
[[package]] [[package]]
@@ -1426,7 +1487,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
dependencies = [ dependencies = [
"proc-macro-error-attr", "proc-macro-error-attr",
"proc-macro2 1.0.78", "proc-macro2 1.0.103",
"quote 1.0.35", "quote 1.0.35",
"syn 1.0.109", "syn 1.0.109",
"version_check", "version_check",
@@ -1438,7 +1499,7 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
dependencies = [ dependencies = [
"proc-macro2 1.0.78", "proc-macro2 1.0.103",
"quote 1.0.35", "quote 1.0.35",
"version_check", "version_check",
] ]
@@ -1466,9 +1527,9 @@ dependencies = [
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.78" version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" checksum = "5ee95bc4ef87b8d5ba32e8b7714ccc834865276eab0aed5c9958d00ec45f49e8"
dependencies = [ dependencies = [
"unicode-ident", "unicode-ident",
] ]
@@ -1490,7 +1551,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8021cf59c8ec9c432cfc2526ac6b8aa508ecaf29cd415f271b8406c1b851c3fd" checksum = "8021cf59c8ec9c432cfc2526ac6b8aa508ecaf29cd415f271b8406c1b851c3fd"
dependencies = [ dependencies = [
"quote 1.0.35", "quote 1.0.35",
"syn 2.0.52", "syn 2.0.108",
] ]
[[package]] [[package]]
@@ -1508,7 +1569,7 @@ version = "1.0.35"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef"
dependencies = [ dependencies = [
"proc-macro2 1.0.78", "proc-macro2 1.0.103",
] ]
[[package]] [[package]]
@@ -1563,13 +1624,13 @@ dependencies = [
[[package]] [[package]]
name = "rbx_binary" name = "rbx_binary"
version = "1.0.0" version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9573fee5e073d7b303f475c285197fdc8179468de66ca60ee115a58fbac99296" checksum = "0d419f67c8012bf83569086e1208c541478b3b8e4f523deaa0b80d723fb5ef22"
dependencies = [ dependencies = [
"ahash", "ahash",
"log", "log",
"lz4", "lz4_flex",
"profiling", "profiling",
"rbx_dom_weak", "rbx_dom_weak",
"rbx_reflection", "rbx_reflection",
@@ -1580,9 +1641,9 @@ dependencies = [
[[package]] [[package]]
name = "rbx_dom_weak" name = "rbx_dom_weak"
version = "3.0.0" version = "4.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04425cf6e9376e5486f4fb35906c120d1b1b45618a490318cf563fab1fa230a9" checksum = "bc74878a4a801afc8014b14ede4b38015a13de5d29ab0095d5ed284a744253f6"
dependencies = [ dependencies = [
"ahash", "ahash",
"rbx_types", "rbx_types",
@@ -1592,9 +1653,9 @@ dependencies = [
[[package]] [[package]]
name = "rbx_reflection" name = "rbx_reflection"
version = "5.0.0" version = "6.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1b6d0d62baa613556b058a5f94a53b01cf0ccde0ea327ce03056e335b982e77e" checksum = "565dd3430991f35443fa6d23cc239fade2110c5089deb6bae5de77c400df4fd2"
dependencies = [ dependencies = [
"rbx_types", "rbx_types",
"serde", "serde",
@@ -1603,11 +1664,12 @@ dependencies = [
[[package]] [[package]]
name = "rbx_reflection_database" name = "rbx_reflection_database"
version = "1.0.3+roblox-670" version = "2.0.1+roblox-697"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e22c05ef92528c0fb0cc580592a65ca178d3ea9beb07a1d9ca0a2503c4f3721c" checksum = "d69035a14b103c5a9b8bc6a61d30f4ee6f2608afdee137dae09b26037dba5dc8"
dependencies = [ dependencies = [
"lazy_static", "dirs 5.0.1",
"log",
"rbx_reflection", "rbx_reflection",
"rmp-serde", "rmp-serde",
"serde", "serde",
@@ -1615,9 +1677,9 @@ dependencies = [
[[package]] [[package]]
name = "rbx_types" name = "rbx_types"
version = "2.0.0" version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78e4fdde46493def107e5f923d82e813dec9b3eef52c2f75fbad3a716023eda2" checksum = "03220ffce2bd06ad04f77a003cb807f2e5b2a18e97623066a5ac735a978398af"
dependencies = [ dependencies = [
"base64 0.13.1", "base64 0.13.1",
"bitflags 1.3.2", "bitflags 1.3.2",
@@ -1630,9 +1692,9 @@ dependencies = [
[[package]] [[package]]
name = "rbx_xml" name = "rbx_xml"
version = "1.0.0" version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb623833c31cc43bbdaeb32f5e91db8ecd63fc46e438d0d268baf9e61539cf1c" checksum = "be6c302cefe9c92ed09bcbb075cd24379271de135b0af331409a64c2ea3646ee"
dependencies = [ dependencies = [
"ahash", "ahash",
"base64 0.13.1", "base64 0.13.1",
@@ -1824,14 +1886,14 @@ version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bb8c693a387f1ae8d2026d82d8b0c175cc4777b97c1f7b12fdb3be595bb13" checksum = "743bb8c693a387f1ae8d2026d82d8b0c175cc4777b97c1f7b12fdb3be595bb13"
dependencies = [ dependencies = [
"dirs", "dirs 2.0.2",
"thiserror", "thiserror",
"winreg 0.6.2", "winreg 0.6.2",
] ]
[[package]] [[package]]
name = "rojo" name = "rojo"
version = "7.5.1" version = "7.6.1"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"backtrace", "backtrace",
@@ -1840,6 +1902,7 @@ dependencies = [
"criterion", "criterion",
"crossbeam-channel", "crossbeam-channel",
"csv", "csv",
"data-encoding",
"embed-resource", "embed-resource",
"env_logger", "env_logger",
"fs-err", "fs-err",
@@ -1849,6 +1912,7 @@ dependencies = [
"hyper", "hyper",
"insta", "insta",
"jod-thread", "jod-thread",
"jsonc-parser",
"log", "log",
"maplit", "maplit",
"memofs", "memofs",
@@ -1879,6 +1943,7 @@ dependencies = [
"uuid", "uuid",
"walkdir", "walkdir",
"winreg 0.10.1", "winreg 0.10.1",
"yaml-rust2",
] ]
[[package]] [[package]]
@@ -1999,10 +2064,11 @@ checksum = "92d43fe69e652f3df9bdc2b85b2854a0825b86e4fb76bc44d945137d053639ca"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.197" version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fb1c873e1b9b056a4dc4c0c198b24c3ffa059243875552b2bd0933b1aee4ce2" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [ dependencies = [
"serde_core",
"serde_derive", "serde_derive",
] ]
@@ -2017,25 +2083,36 @@ dependencies = [
] ]
[[package]] [[package]]
name = "serde_derive" name = "serde_core"
version = "1.0.197" version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7eb0b34b42edc17f6b7cac84a52a1c5f0e1bb2227e997ca9011ea3dd34e8610b" checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
dependencies = [ dependencies = [
"proc-macro2 1.0.78", "serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
dependencies = [
"proc-macro2 1.0.103",
"quote 1.0.35", "quote 1.0.35",
"syn 2.0.52", "syn 2.0.108",
] ]
[[package]] [[package]]
name = "serde_json" name = "serde_json"
version = "1.0.114" version = "1.0.145"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c5f09b1bd632ef549eaa9f60a1f8de742bdbc698e6cee2095fc84dde5f549ae0" checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c"
dependencies = [ dependencies = [
"itoa", "itoa",
"memchr",
"ryu", "ryu",
"serde", "serde",
"serde_core",
] ]
[[package]] [[package]]
@@ -2140,18 +2217,18 @@ version = "1.0.109"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237"
dependencies = [ dependencies = [
"proc-macro2 1.0.78", "proc-macro2 1.0.103",
"quote 1.0.35", "quote 1.0.35",
"unicode-ident", "unicode-ident",
] ]
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.52" version = "2.0.108"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b699d15b36d1f02c3e7c69f8ffef53de37aefae075d8488d4ba1a7788d574a07" checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917"
dependencies = [ dependencies = [
"proc-macro2 1.0.78", "proc-macro2 1.0.103",
"quote 1.0.35", "quote 1.0.35",
"unicode-ident", "unicode-ident",
] ]
@@ -2234,9 +2311,9 @@ version = "1.0.57"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a953cb265bef375dae3de6663da4d3804eee9682ea80d8e2542529b73c531c81" checksum = "a953cb265bef375dae3de6663da4d3804eee9682ea80d8e2542529b73c531c81"
dependencies = [ dependencies = [
"proc-macro2 1.0.78", "proc-macro2 1.0.103",
"quote 1.0.35", "quote 1.0.35",
"syn 2.0.52", "syn 2.0.108",
] ]
[[package]] [[package]]
@@ -2346,9 +2423,9 @@ version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7"
dependencies = [ dependencies = [
"proc-macro2 1.0.78", "proc-macro2 1.0.103",
"quote 1.0.35", "quote 1.0.35",
"syn 2.0.52", "syn 2.0.108",
] ]
[[package]] [[package]]
@@ -2416,6 +2493,12 @@ version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]]
name = "twox-hash"
version = "2.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.17.0" version = "1.17.0"
@@ -2577,9 +2660,9 @@ dependencies = [
"bumpalo", "bumpalo",
"log", "log",
"once_cell", "once_cell",
"proc-macro2 1.0.78", "proc-macro2 1.0.103",
"quote 1.0.35", "quote 1.0.35",
"syn 2.0.52", "syn 2.0.108",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@@ -2611,9 +2694,9 @@ version = "0.2.92"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7" checksum = "e94f17b526d0a461a191c78ea52bbce64071ed5c04c9ffe424dcb38f74171bb7"
dependencies = [ dependencies = [
"proc-macro2 1.0.78", "proc-macro2 1.0.103",
"quote 1.0.35", "quote 1.0.35",
"syn 2.0.52", "syn 2.0.108",
"wasm-bindgen-backend", "wasm-bindgen-backend",
"wasm-bindgen-shared", "wasm-bindgen-shared",
] ]
@@ -2877,6 +2960,17 @@ dependencies = [
"linked-hash-map", "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]] [[package]]
name = "yansi" name = "yansi"
version = "0.5.1" version = "0.5.1"
@@ -2898,9 +2992,9 @@ version = "0.7.35"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e"
dependencies = [ dependencies = [
"proc-macro2 1.0.78", "proc-macro2 1.0.103",
"quote 1.0.35", "quote 1.0.35",
"syn 2.0.52", "syn 2.0.108",
] ]
[[package]] [[package]]

View File

@@ -1,8 +1,12 @@
[package] [package]
name = "rojo" name = "rojo"
version = "7.5.1" version = "7.6.1"
rust-version = "1.70.0" rust-version = "1.88"
authors = ["Lucien Greathouse <me@lpghatguy.com>"] 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" description = "Enables professional-grade development tools for Roblox developers"
license = "MPL-2.0" license = "MPL-2.0"
homepage = "https://rojo.space" 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_reflection_database = { path = "../rbx-dom/rbx_reflection_database" }
# rbx_xml = { path = "../rbx-dom/rbx_xml" } # rbx_xml = { path = "../rbx-dom/rbx_xml" }
rbx_binary = "1.0.0" rbx_binary = "2.0.0"
rbx_dom_weak = "3.0.0" rbx_dom_weak = "4.0.0"
rbx_reflection = "5.0.0" rbx_reflection = "6.0.0"
rbx_reflection_database = "1.0.3" rbx_reflection_database = "2.0.1"
rbx_xml = "1.0.0" rbx_xml = "2.0.0"
anyhow = "1.0.80" anyhow = "1.0.80"
backtrace = "0.3.69" backtrace = "0.3.69"
@@ -81,7 +85,8 @@ reqwest = { version = "0.11.24", default-features = false, features = [
ritz = "0.1.0" ritz = "0.1.0"
roblox_install = "1.0.0" roblox_install = "1.0.0"
serde = { version = "1.0.197", features = ["derive", "rc"] } serde = { version = "1.0.197", features = ["derive", "rc"] }
serde_json = "1.0.114" serde_json = "1.0.145"
jsonc-parser = { version = "0.27.0", features = ["serde"] }
toml = "0.5.11" toml = "0.5.11"
termcolor = "1.4.1" termcolor = "1.4.1"
thiserror = "1.0.57" thiserror = "1.0.57"
@@ -89,6 +94,8 @@ tokio = { version = "1.36.0", features = ["rt", "rt-multi-thread"] }
uuid = { version = "1.7.0", features = ["v4", "serde"] } uuid = { version = "1.7.0", features = ["v4", "serde"] }
clap = { version = "3.2.25", features = ["derive"] } clap = { version = "3.2.25", features = ["derive"] }
profiling = "1.0.15" profiling = "1.0.15"
yaml-rust2 = "0.10.3"
data-encoding = "2.8.0"
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
winreg = "0.10.1" 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! 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.88 and newer. The minimum supported version of Rust is based on the latest versions of the dependencies that Rojo has.
## License ## License
Rojo is available under the terms of the Mozilla Public License, Version 2.0. See [LICENSE.txt](LICENSE.txt) for details. 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; line-height: 1.4;
} }
body {
background-color: #e7e7e7
}
img { img {
max-width:100%; max-width:100%;
max-height:100%; max-height:100%;

View File

@@ -1,3 +1,5 @@
# Roblox Studio lock files # Roblox Studio lock files
/*.rbxlx.lock /*.rbxlx.lock
/*.rbxl.lock /*.rbxl.lock
sourcemap.json

View File

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

View File

@@ -3,4 +3,6 @@
# Roblox Studio lock files # Roblox Studio lock files
/*.rbxlx.lock /*.rbxlx.lock
/*.rbxl.lock /*.rbxl.lock
sourcemap.json

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

@@ -1,3 +1,5 @@
# Plugin model files # Plugin model files
/{project_name}.rbxmx /{project_name}.rbxmx
/{project_name}.rbxm /{project_name}.rbxm
sourcemap.json

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

View File

@@ -2,7 +2,11 @@
name = "memofs" name = "memofs"
description = "Virtual filesystem with configurable backends." description = "Virtual filesystem with configurable backends."
version = "0.3.0" 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" edition = "2018"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"

View File

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

View File

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

View File

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

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) { pub fn intern(&mut self, id: impl ToString) {
let last_id = &mut self.last_id; let last_id = &mut self.last_id;

View File

@@ -1 +1 @@
7.5.1 7.6.1

View File

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

View File

@@ -441,7 +441,8 @@
"friction": 1.0, "friction": 1.0,
"elasticity": 0.0, "elasticity": 0.0,
"frictionWeight": 50.0, "frictionWeight": 50.0,
"elasticityWeight": 25.0 "elasticityWeight": 25.0,
"acousticAbsorption": 0.15625
} }
}, },
"ty": "PhysicalProperties" "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 validateApiInfo = Types.ifEnabled(Types.ApiInfoResponse)
local validateApiRead = Types.ifEnabled(Types.ApiReadResponse) local validateApiRead = Types.ifEnabled(Types.ApiReadResponse)
local validateApiSubscribe = Types.ifEnabled(Types.ApiSubscribeResponse) local validateApiSubscribe = Types.ifEnabled(Types.ApiSubscribeResponse)
local validateApiSerialize = Types.ifEnabled(Types.ApiSerializeResponse)
local validateApiRefPatch = Types.ifEnabled(Types.ApiRefPatchResponse)
local function rejectFailedRequests(response) local function rejectFailedRequests(response)
if response.code >= 400 then if response.code >= 400 then
@@ -252,4 +254,32 @@ function ApiContext:open(id)
end) end)
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 return ApiContext

View File

@@ -9,8 +9,70 @@ local Assets = require(Plugin.Assets)
local Config = require(Plugin.Config) local Config = require(Plugin.Config)
local Version = require(Plugin.Version) local Version = require(Plugin.Version)
local Tooltip = require(Plugin.App.Components.Tooltip)
local SlicedImage = require(script.Parent.SlicedImage)
local e = Roact.createElement 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) local function Header(props)
return Theme.with(function(theme) return Theme.with(function(theme)
return e("Frame", { return e("Frame", {
@@ -29,18 +91,9 @@ local function Header(props)
BackgroundTransparency = 1, BackgroundTransparency = 1,
}), }),
Version = e("TextLabel", { VersionIndicator = e(VersionIndicator, {
Text = Version.display(Config.version), transparency = props.transparency,
FontFace = theme.Font.Thin, layoutOrder = 2,
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,
}), }),
Layout = e("UIListLayout", { 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 BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local TextButton = require(Plugin.App.Components.TextButton) local TextButton = require(Plugin.App.Components.TextButton)
local baseClock = DateTime.now().UnixTimestampMillis
local e = Roact.createElement local e = Roact.createElement
local Notification = Roact.Component:extend("Notification") local Notification = Roact.Component:extend("Notification")
@@ -77,7 +75,9 @@ function Notification:didMount()
end end
function Notification:willUnmount() function Notification:willUnmount()
task.cancel(self.timeout) if self.timeout and coroutine.status(self.timeout) ~= "dead" then
task.cancel(self.timeout)
end
end end
function Notification:render() function Notification:render()
@@ -95,9 +95,12 @@ function Notification:render()
text = action.text, text = action.text,
style = action.style, style = action.style,
onClick = function() onClick = function()
local success, err = pcall(action.onClick, self) self:dismiss()
if not success then if action.onClick then
Log.warn("Error in notification action: " .. tostring(err)) local success, err = pcall(action.onClick, self)
if not success then
Log.warn("Error in notification action: " .. tostring(err))
end
end end
end, end,
layoutOrder = -action.layoutOrder, layoutOrder = -action.layoutOrder,
@@ -138,17 +141,17 @@ function Notification:render()
}, { }, {
e(BorderedContainer, { e(BorderedContainer, {
transparency = transparency, transparency = transparency,
size = UDim2.new(1, 0, 1, 0), size = UDim2.fromScale(1, 1),
}, { }, {
Contents = e("Frame", { Contents = e("Frame", {
Size = UDim2.new(1, 0, 1, 0), Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1, BackgroundTransparency = 1,
}, { }, {
Logo = e("ImageLabel", { Logo = e("ImageLabel", {
ImageTransparency = transparency, ImageTransparency = transparency,
Image = Assets.Images.PluginButton, Image = Assets.Images.PluginButton,
BackgroundTransparency = 1, BackgroundTransparency = 1,
Size = UDim2.new(0, logoSize, 0, logoSize), Size = UDim2.fromOffset(logoSize, logoSize),
Position = UDim2.new(0, 0, 0, 0), Position = UDim2.new(0, 0, 0, 0),
AnchorPoint = Vector2.new(0, 0), AnchorPoint = Vector2.new(0, 0),
}), }),
@@ -171,7 +174,7 @@ function Notification:render()
Actions = if self.props.actions Actions = if self.props.actions
then e("Frame", { then e("Frame", {
Size = UDim2.new(1, -40, 0, actionsY), Size = UDim2.new(1, -40, 0, actionsY),
Position = UDim2.new(1, 0, 1, 0), Position = UDim2.fromScale(1, 1),
AnchorPoint = Vector2.new(1, 1), AnchorPoint = Vector2.new(1, 1),
BackgroundTransparency = 1, BackgroundTransparency = 1,
}, { }, {
@@ -198,26 +201,4 @@ function Notification:render()
end) end)
end end
local Notifications = Roact.Component:extend("Notifications") return Notification
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

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) return Theme.with(function(theme)
local color = if props.isWarning local color = if props.isWarning
then theme.Diff.Warning then theme.Diff.Warning
elseif props.patchType then theme.Diff[props.patchType] elseif props.patchType then theme.Diff.Background[props.patchType]
else theme.TextColor else theme.TextColor
local indent = (depth - 1) * 12 + 15 local indent = (depth - 1) * 12 + 15

View File

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

View File

@@ -93,7 +93,7 @@ function Array:render()
e("Frame", { e("Frame", {
Size = UDim2.new(1, 0, 0, 25), Size = UDim2.new(1, 0, 0, 25),
BackgroundTransparency = if patchType == "Remain" then 1 else self.props.transparency, 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, BorderSizePixel = 0,
LayoutOrder = i, LayoutOrder = i,
}, { }, {

View File

@@ -91,9 +91,7 @@ function Dictionary:render()
LayoutOrder = order, LayoutOrder = order,
BorderSizePixel = 0, BorderSizePixel = 0,
BackgroundTransparency = if line.patchType == "Remain" then 1 else self.props.transparency, BackgroundTransparency = if line.patchType == "Remain" then 1 else self.props.transparency,
BackgroundColor3 = if line.patchType == "Remain" BackgroundColor3 = theme.Diff.Background[line.patchType],
then theme.Diff.Row
else theme.Diff[line.patchType],
}, { }, {
DiffIcon = if line.patchType ~= "Remain" DiffIcon = if line.patchType ~= "Remain"
then e("ImageLabel", { then e("ImageLabel", {
@@ -114,7 +112,7 @@ function Dictionary:render()
TextXAlignment = Enum.TextXAlignment.Left, TextXAlignment = Enum.TextXAlignment.Left,
FontFace = theme.Font.Main, FontFace = theme.Font.Main,
TextSize = theme.TextSize.Body, TextSize = theme.TextSize.Body,
TextColor3 = theme.Settings.Setting.DescriptionColor, TextColor3 = theme.Diff.Text[line.patchType],
TextTruncate = Enum.TextTruncate.AtEnd, TextTruncate = Enum.TextTruncate.AtEnd,
}), }),
OldValue = e("Frame", { OldValue = e("Frame", {
@@ -125,7 +123,7 @@ function Dictionary:render()
e(DisplayValue, { e(DisplayValue, {
value = oldValue, value = oldValue,
transparency = self.props.transparency, transparency = self.props.transparency,
textColor = theme.Settings.Setting.DescriptionColor, textColor = theme.Diff.Text[line.patchType],
}), }),
}), }),
NewValue = e("Frame", { NewValue = e("Frame", {
@@ -136,7 +134,7 @@ function Dictionary:render()
e(DisplayValue, { e(DisplayValue, {
value = newValue, value = newValue,
transparency = self.props.transparency, 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 return
end end
self.showDelayThread = task.delay(DELAY, function() self.showDelayThread = task.delay(self.props.delay or DELAY, function()
self.props.context.addTip(self.id, { self.props.context.addTip(self.id, {
Text = self.props.text, Text = self.props.text,
Position = self:getMousePos(), Position = self:getMousePos(),

View File

@@ -4,8 +4,6 @@ local Packages = Rojo.Packages
local Roact = require(Packages.Roact) local Roact = require(Packages.Roact)
local Timer = require(Plugin.Timer)
local PatchTree = require(Plugin.PatchTree)
local Settings = require(Plugin.Settings) local Settings = require(Plugin.Settings)
local Theme = require(Plugin.App.Theme) local Theme = require(Plugin.App.Theme)
local TextButton = require(Plugin.App.Components.TextButton) local TextButton = require(Plugin.App.Components.TextButton)
@@ -24,7 +22,6 @@ function ConfirmingPage:init()
self.containerSize, self.setContainerSize = Roact.createBinding(Vector2.new(0, 0)) self.containerSize, self.setContainerSize = Roact.createBinding(Vector2.new(0, 0))
self:setState({ self:setState({
patchTree = nil,
showingStringDiff = false, showingStringDiff = false,
oldString = "", oldString = "",
newString = "", newString = "",
@@ -32,28 +29,6 @@ function ConfirmingPage:init()
oldTable = {}, oldTable = {},
newTable = {}, newTable = {},
}) })
if self.props.confirmData and self.props.confirmData.patch and self.props.confirmData.instanceMap then
self:buildPatchTree()
end
end
function ConfirmingPage:didUpdate(prevProps)
if prevProps.confirmData ~= self.props.confirmData then
self:buildPatchTree()
end
end
function ConfirmingPage:buildPatchTree()
Timer.start("ConfirmingPage:buildPatchTree")
self:setState({
patchTree = PatchTree.build(
self.props.confirmData.patch,
self.props.confirmData.instanceMap,
{ "Property", "Current", "Incoming" }
),
})
Timer.stop()
end end
function ConfirmingPage:render() function ConfirmingPage:render()
@@ -79,7 +54,7 @@ function ConfirmingPage:render()
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 3, layoutOrder = 3,
patchTree = self.state.patchTree, patchTree = self.props.patchTree,
showStringDiff = function(oldString: string, newString: string) showStringDiff = function(oldString: string, newString: string)
self:setState({ self:setState({

View File

@@ -4,6 +4,8 @@ local Packages = Rojo.Packages
local Roact = require(Packages.Roact) local Roact = require(Packages.Roact)
local Theme = require(Plugin.App.Theme)
local Spinner = require(Plugin.App.Components.Spinner) local Spinner = require(Plugin.App.Components.Spinner)
local e = Roact.createElement local e = Roact.createElement
@@ -11,11 +13,35 @@ local e = Roact.createElement
local ConnectingPage = Roact.Component:extend("ConnectingPage") local ConnectingPage = Roact.Component:extend("ConnectingPage")
function ConnectingPage:render() function ConnectingPage:render()
return e(Spinner, { return Theme.with(function(theme)
position = UDim2.new(0.5, 0, 0.5, 0), return e("Frame", {
anchorPoint = Vector2.new(0.5, 0.5), Size = UDim2.new(1, 0, 1, 0),
transparency = self.props.transparency, BackgroundTransparency = 1,
}) }, {
Spinner = e(Spinner, {
position = UDim2.new(0.5, 0, 0.5, 0),
anchorPoint = Vector2.new(0.5, 0.5),
transparency = self.props.transparency,
}),
Text = if type(self.props.text) == "string" and #self.props.text > 0
then e("TextLabel", {
Text = self.props.text,
Position = UDim2.new(0.5, 0, 0.5, 30),
Size = UDim2.new(1, -40, 0.5, -40),
AnchorPoint = Vector2.new(0.5, 0),
TextXAlignment = Enum.TextXAlignment.Center,
TextYAlignment = Enum.TextYAlignment.Top,
RichText = true,
FontFace = theme.Font.Thin,
TextSize = theme.TextSize.Medium,
TextColor3 = theme.SubTextColor,
TextTruncate = Enum.TextTruncate.AtEnd,
TextTransparency = self.props.transparency,
BackgroundTransparency = 1,
})
else nil,
})
end)
end end
return ConnectingPage return ConnectingPage

View File

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

View File

@@ -27,6 +27,7 @@ end
local invertedLevels = invertTbl(Log.Level) local invertedLevels = invertTbl(Log.Level)
local confirmationBehaviors = { "Initial", "Always", "Large Changes", "Unlisted PlaceId", "Never" } local confirmationBehaviors = { "Initial", "Always", "Large Changes", "Unlisted PlaceId", "Never" }
local syncReminderModes = { "None", "Notify", "Fullscreen" }
local function Navbar(props) local function Navbar(props)
return Theme.with(function(theme) return Theme.with(function(theme)
@@ -93,6 +94,14 @@ function SettingsPage:render()
contentSize = self.contentSize, contentSize = self.contentSize,
transparency = self.props.transparency, 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, { ShowNotifications = e(Setting, {
id = "showNotifications", id = "showNotifications",
name = "Show Notifications", name = "Show Notifications",
@@ -101,13 +110,26 @@ function SettingsPage:render()
layoutOrder = layoutIncrement(), layoutOrder = layoutIncrement(),
}), }),
SyncReminder = e(Setting, { SyncReminderMode = e(Setting, {
id = "syncReminder", id = "syncReminderMode",
name = "Sync Reminder", 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, transparency = self.props.transparency,
visible = Settings:getBinding("showNotifications"),
layoutOrder = layoutIncrement(), 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, { ConfirmationBehavior = e(Setting, {
@@ -159,6 +181,14 @@ function SettingsPage:render()
layoutOrder = layoutIncrement(), 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, { CheckForUpdates = e(Setting, {
id = "checkForUpdates", id = "checkForUpdates",
name = "Check For Updates", name = "Check For Updates",

View File

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

View File

@@ -9,6 +9,7 @@ local Packages = Rojo.Packages
local Roact = require(Packages.Roact) local Roact = require(Packages.Roact)
local Log = require(Packages.Log) local Log = require(Packages.Log)
local Promise = require(Packages.Promise)
local Assets = require(Plugin.Assets) local Assets = require(Plugin.Assets)
local Version = require(Plugin.Version) local Version = require(Plugin.Version)
@@ -27,7 +28,7 @@ local timeUtil = require(Plugin.timeUtil)
local Theme = require(script.Theme) local Theme = require(script.Theme)
local Page = require(script.Page) local Page = require(script.Page)
local Notifications = require(script.Notifications) local Notifications = require(script.Components.Notifications)
local Tooltip = require(script.Components.Tooltip) local Tooltip = require(script.Components.Tooltip)
local StudioPluginAction = require(script.Components.Studio.StudioPluginAction) local StudioPluginAction = require(script.Components.Studio.StudioPluginAction)
local StudioToolbar = require(script.Components.Studio.StudioToolbar) local StudioToolbar = require(script.Components.Studio.StudioToolbar)
@@ -78,17 +79,18 @@ function App:init()
action action
) )
) )
local dismissNotif = self:addNotification( local dismissNotif = self:addNotification({
string.format("You've undone '%s'.\nIf this was not intended, please restore.", action), text = string.format("You've undone '%s'.\nIf this was not intended, please restore.", action),
10, timeout = 10,
{ onClose = function()
cleanup()
end,
actions = {
Restore = { Restore = {
text = "Restore", text = "Restore",
style = "Solid", style = "Solid",
layoutOrder = 1, layoutOrder = 1,
onClick = function(notification) onClick = function()
cleanup()
notification:dismiss()
ChangeHistoryService:Redo() ChangeHistoryService:Redo()
end, end,
}, },
@@ -96,13 +98,9 @@ function App:init()
text = "Dismiss", text = "Dismiss",
style = "Bordered", style = "Bordered",
layoutOrder = 2, layoutOrder = 2,
onClick = function(notification)
cleanup()
notification:dismiss()
end,
}, },
} },
) })
undoConnection = ChangeHistoryService.OnUndo:Once(function() undoConnection = ChangeHistoryService.OnUndo:Once(function()
-- Our notif is now out of date- redoing will not restore the patch -- Our notif is now out of date- redoing will not restore the patch
@@ -142,42 +140,20 @@ function App:init()
if RunService:IsEdit() then if RunService:IsEdit() then
self:checkForUpdates() self:checkForUpdates()
if self:startSyncReminderPolling()
Settings:get("syncReminder") self.disconnectSyncReminderPollingChanged = Settings:onChanged("syncReminderPolling", function(enabled)
and self.serveSession == nil if enabled then
and self:getPriorSyncInfo().timestamp ~= nil self:startSyncReminderPolling()
and (self:isSyncLockAvailable()) else
then self:stopSyncReminderPolling()
local syncInfo = self:getPriorSyncInfo() end
local timeSinceSync = timeUtil.elapsedToText(os.time() - syncInfo.timestamp) end)
local syncDetail = if syncInfo.projectName
then `project '{syncInfo.projectName}'`
else `{syncInfo.host or Config.defaultHost}:{syncInfo.port or Config.defaultPort}`
self:addNotification( self:tryAutoReconnect():andThen(function(didReconnect)
`You synced {syncDetail} to this place {timeSinceSync}. Would you like to reconnect?`, if not didReconnect then
300, self:checkSyncReminder()
{ end
Connect = { end)
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
end end
if self:isAutoConnectPlaytestServerAvailable() then if self:isAutoConnectPlaytestServerAvailable() then
@@ -203,16 +179,23 @@ function App:willUnmount()
self.disconnectUpdatesCheckChanged() self.disconnectUpdatesCheckChanged()
self.disconnectPrereleasesCheckChanged() self.disconnectPrereleasesCheckChanged()
if self.disconnectSyncReminderPollingChanged then
self.disconnectSyncReminderPollingChanged()
end
self:stopSyncReminderPolling()
self.autoConnectPlaytestServerListener() self.autoConnectPlaytestServerListener()
self:clearRunningConnectionInfo() self:clearRunningConnectionInfo()
end end
function App:addNotification( function App:addNotification(notif: {
text: string, text: string,
isFullscreen: boolean?,
timeout: number?, 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 if not Settings:get("showNotifications") then
return return
end end
@@ -220,17 +203,17 @@ function App:addNotification(
self.notifId += 1 self.notifId += 1
local id = self.notifId local id = self.notifId
local notifications = table.clone(self.state.notifications) self:setState(function(prevState)
notifications[id] = { local notifications = table.clone(prevState.notifications)
text = text, notifications[id] = Dictionary.merge({
timestamp = DateTime.now().UnixTimestampMillis, timeout = notif.timeout or 5,
timeout = timeout or 3, isFullscreen = notif.isFullscreen or false,
actions = actions, }, notif)
}
self:setState({ return {
notifications = notifications, notifications = notifications,
}) }
end)
return function() return function()
self:closeNotification(id) self:closeNotification(id)
@@ -242,46 +225,32 @@ function App:closeNotification(id: number)
return return
end end
local notifications = table.clone(self.state.notifications) self:setState(function(prevState)
notifications[id] = nil local notifications = table.clone(prevState.notifications)
notifications[id] = nil
self:setState({ return {
notifications = notifications, notifications = notifications,
}) }
end)
end end
function App:checkForUpdates() function App:checkForUpdates()
if not Settings:get("checkForUpdates") then local updateMessage = Version.getUpdateMessage()
return
end
local isLocalInstall = string.find(debug.traceback(), "\n[^\n]-user_.-$") ~= nil if updateMessage then
local latestCompatibleVersion = Version.retrieveLatestCompatible({ self:addNotification({
version = Config.version, text = updateMessage,
includePrereleases = isLocalInstall and Settings:get("checkForPrereleases"), timeout = 500,
}) actions = {
if not latestCompatibleVersion then Dismiss = {
return text = "Dismiss",
end style = "Bordered",
layoutOrder = 2,
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,
}, },
} })
) end
end end
function App:getPriorSyncInfo(): { host: string?, port: string?, projectName: string?, timestamp: number? } 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) Log.trace("Could not relase sync lock because it is owned by {}", lock.Value)
end 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() function App:isAutoConnectPlaytestServerAvailable()
return RunService:IsRunMode() return RunService:IsRunning()
and RunService:IsStudio()
and RunService:IsServer() and RunService:IsServer()
and Settings:get("autoConnectPlaytestServer") and Settings:get("autoConnectPlaytestServer")
and workspace:GetAttribute("__Rojo_ConnectionUrl") 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)) local msg = string.format("Could not sync because user '%s' is already syncing", tostring(priorOwner))
Log.warn(msg) Log.warn(msg)
self:addNotification(msg, 10) self:addNotification({
text = msg,
timeout = 10,
})
self:setState({ self:setState({
appStatus = AppStatus.Error, appStatus = AppStatus.Error,
errorMessage = msg, errorMessage = msg,
@@ -473,60 +595,59 @@ function App:startSession()
twoWaySync = Settings:get("twoWaySync"), twoWaySync = Settings:get("twoWaySync"),
}) })
self.cleanupPrecommit = serveSession.__reconciler:hookPrecommit(function(patch, instanceMap) serveSession:setUpdateLoadingTextCallback(function(text: string)
self:setState({
connectingText = text,
})
end)
self.cleanupPrecommit = serveSession:hookPrecommit(function(patch, instanceMap)
-- Build new tree for patch -- Build new tree for patch
self:setState({ self:setState({
patchTree = PatchTree.build(patch, instanceMap, { "Property", "Old", "New" }), patchTree = PatchTree.build(patch, instanceMap, { "Property", "Old", "New" }),
}) })
end) end)
self.cleanupPostcommit = serveSession.__reconciler:hookPostcommit(function(patch, instanceMap, unappliedPatch) self.cleanupPostcommit = serveSession:hookPostcommit(function(patch, instanceMap, unappliedPatch)
-- Update tree with unapplied metadata local now = DateTime.now().UnixTimestamp
self:setState(function(prevState) self:setState(function(prevState)
local oldPatchData = prevState.patchData
local newPatchData = {
patch = patch,
unapplied = unappliedPatch,
timestamp = now,
}
if PatchSet.isEmpty(patch) then
-- Keep existing patch info, but use new timestamp
newPatchData.patch = oldPatchData.patch
newPatchData.unapplied = oldPatchData.unapplied
elseif now - oldPatchData.timestamp < 2 then
-- Patches that apply in the same second are combined for human clarity
newPatchData.patch = PatchSet.assign(PatchSet.newEmpty(), oldPatchData.patch, patch)
newPatchData.unapplied = PatchSet.assign(PatchSet.newEmpty(), oldPatchData.unapplied, unappliedPatch)
end
return { return {
patchTree = PatchTree.updateMetadata(prevState.patchTree, patch, instanceMap, unappliedPatch), patchTree = PatchTree.updateMetadata(prevState.patchTree, patch, instanceMap, unappliedPatch),
patchData = newPatchData,
} }
end) end)
end) end)
serveSession:hookPostcommit(function(patch, _instanceMap, unapplied)
local now = DateTime.now().UnixTimestamp
local old = self.state.patchData
if PatchSet.isEmpty(patch) then
-- Ignore empty patch, but update timestamp
self:setState({
patchData = {
patch = old.patch,
unapplied = old.unapplied,
timestamp = now,
},
})
return
end
if now - old.timestamp < 2 then
-- Patches that apply in the same second are
-- considered to be part of the same change for human clarity
patch = PatchSet.assign(PatchSet.newEmpty(), old.patch, patch)
unapplied = PatchSet.assign(PatchSet.newEmpty(), old.unapplied, unapplied)
end
self:setState({
patchData = {
patch = patch,
unapplied = unapplied,
timestamp = now,
},
})
end)
serveSession:onStatusChanged(function(status, details) serveSession:onStatusChanged(function(status, details)
if status == ServeSession.Status.Connecting then if status == ServeSession.Status.Connecting then
if self.dismissSyncReminder then
self.dismissSyncReminder()
self.dismissSyncReminder = nil
end
self:setState({ self:setState({
appStatus = AppStatus.Connecting, appStatus = AppStatus.Connecting,
toolbarIcon = Assets.Images.PluginButton, toolbarIcon = Assets.Images.PluginButton,
}) })
self:addNotification("Connecting to session...") self:addNotification({
text = "Connecting to session...",
})
elseif status == ServeSession.Status.Connected then elseif status == ServeSession.Status.Connected then
self.knownProjects[details] = true self.knownProjects[details] = true
self:setPriorSyncInfo(host, port, details) self:setPriorSyncInfo(host, port, details)
@@ -539,7 +660,9 @@ function App:startSession()
address = address, address = address,
toolbarIcon = Assets.Images.PluginButtonConnected, 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 elseif status == ServeSession.Status.Disconnected then
self.serveSession = nil self.serveSession = nil
self:releaseSyncLock() self:releaseSyncLock()
@@ -562,13 +685,19 @@ function App:startSession()
errorMessage = tostring(details), errorMessage = tostring(details),
toolbarIcon = Assets.Images.PluginButtonWarning, toolbarIcon = Assets.Images.PluginButtonWarning,
}) })
self:addNotification(tostring(details), 10) self:addNotification({
text = tostring(details),
timeout = 10,
})
else else
self:setState({ self:setState({
appStatus = AppStatus.NotConnected, appStatus = AppStatus.NotConnected,
toolbarIcon = Assets.Images.PluginButton, toolbarIcon = Assets.Images.PluginButton,
}) })
self:addNotification("Disconnected from session.") self:addNotification({
text = "Disconnected from session.",
timeout = 10,
})
end end
end end
end) end)
@@ -636,23 +765,25 @@ function App:startSession()
end end
end end
self:setState({
connectingText = "Computing diff view...",
})
self:setState({ self:setState({
appStatus = AppStatus.Confirming, appStatus = AppStatus.Confirming,
patchTree = PatchTree.build(patch, instanceMap, { "Property", "Current", "Incoming" }),
confirmData = { confirmData = {
instanceMap = instanceMap,
patch = patch,
serverInfo = serverInfo, serverInfo = serverInfo,
}, },
toolbarIcon = Assets.Images.PluginButton, toolbarIcon = Assets.Images.PluginButton,
}) })
self:addNotification( self:addNotification({
string.format( text = string.format(
"Please accept%sor abort the initializing sync session.", "Please accept%sor abort the initializing sync session.",
Settings:get("twoWaySync") and ", reject, " or " " Settings:get("twoWaySync") and ", reject, " or " "
), ),
7 timeout = 7,
) })
return self.confirmationEvent:Wait() return self.confirmationEvent:Wait()
end) end)
@@ -751,6 +882,7 @@ function App:render()
ConfirmingPage = createPageElement(AppStatus.Confirming, { ConfirmingPage = createPageElement(AppStatus.Confirming, {
confirmData = self.state.confirmData, confirmData = self.state.confirmData,
patchTree = self.state.patchTree,
createPopup = not self.state.guiEnabled, createPopup = not self.state.guiEnabled,
onAbort = function() onAbort = function()
@@ -764,7 +896,9 @@ function App:render()
end, end,
}), }),
Connecting = createPageElement(AppStatus.Connecting), Connecting = createPageElement(AppStatus.Connecting, {
text = self.state.connectingText,
}),
Connected = createPageElement(AppStatus.Connected, { Connected = createPageElement(AppStatus.Connected, {
projectName = self.state.projectName, projectName = self.state.projectName,
@@ -813,19 +947,7 @@ function App:render()
ResetOnSpawn = false, ResetOnSpawn = false,
DisplayOrder = 100, DisplayOrder = 100,
}, { }, {
layout = e("UIListLayout", { Notifications = e(Notifications, {
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, {
soundPlayer = self.props.soundPlayer, soundPlayer = self.props.soundPlayer,
notifications = self.state.notifications, notifications = self.state.notifications,
onClose = function(id) onClose = function(id)

View File

@@ -282,6 +282,22 @@ function PatchSet.assign(target, ...)
return target return target
end 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 Create a list of human-readable statements summarizing the contents of this
patch, intended to be displayed to users. patch, intended to be displayed to users.

View File

@@ -16,6 +16,14 @@ local Types = require(Plugin.Types)
local decodeValue = require(Plugin.Reconciler.decodeValue) local decodeValue = require(Plugin.Reconciler.decodeValue)
local getProperty = require(Plugin.Reconciler.getProperty) local getProperty = require(Plugin.Reconciler.getProperty)
local function yieldIfNeeded(clock)
if os.clock() - clock > 1 / 20 then
task.wait()
return os.clock()
end
return clock
end
local function alphabeticalNext(t, state) local function alphabeticalNext(t, state)
-- Equivalent of the next function, but returns the keys in the alphabetic -- Equivalent of the next function, but returns the keys in the alphabetic
-- order of node names. We use a temporary ordered key table that is stored in the -- order of node names. We use a temporary ordered key table that is stored in the
@@ -132,7 +140,6 @@ end
-- props must contain id, and cannot contain children or parentId -- props must contain id, and cannot contain children or parentId
-- other than those three, it can hold anything -- other than those three, it can hold anything
function Tree:addNode(parent, props) function Tree:addNode(parent, props)
Timer.start("Tree:addNode")
assert(props.id, "props must contain id") assert(props.id, "props must contain id")
parent = parent or "ROOT" parent = parent or "ROOT"
@@ -143,7 +150,6 @@ function Tree:addNode(parent, props)
for k, v in props do for k, v in props do
node[k] = v node[k] = v
end end
Timer.stop()
return node return node
end end
@@ -154,25 +160,25 @@ function Tree:addNode(parent, props)
local parentNode = self:getNode(parent) local parentNode = self:getNode(parent)
if not parentNode then if not parentNode then
Log.warn("Failed to create node since parent doesnt exist: {}, {}", parent, props) Log.warn("Failed to create node since parent doesnt exist: {}, {}", parent, props)
Timer.stop()
return return
end end
parentNode.children[node.id] = node parentNode.children[node.id] = node
self.idToNode[node.id] = node self.idToNode[node.id] = node
Timer.stop()
return node return node
end end
-- Given a list of ancestor ids in descending order, builds the nodes for them -- Given a list of ancestor ids in descending order, builds the nodes for them
-- using the patch and instanceMap info -- using the patch and instanceMap info
function Tree:buildAncestryNodes(previousId: string?, ancestryIds: { string }, patch, instanceMap) function Tree:buildAncestryNodes(previousId: string?, ancestryIds: { string }, patch, instanceMap)
Timer.start("Tree:buildAncestryNodes") local clock = os.clock()
-- Build nodes for ancestry by going up the tree -- Build nodes for ancestry by going up the tree
previousId = previousId or "ROOT" previousId = previousId or "ROOT"
for _, ancestorId in ancestryIds do for _, ancestorId in ancestryIds do
clock = yieldIfNeeded(clock)
local value = instanceMap.fromIds[ancestorId] or patch.added[ancestorId] local value = instanceMap.fromIds[ancestorId] or patch.added[ancestorId]
if not value then if not value then
Log.warn("Failed to find ancestor object for " .. ancestorId) Log.warn("Failed to find ancestor object for " .. ancestorId)
@@ -186,8 +192,6 @@ function Tree:buildAncestryNodes(previousId: string?, ancestryIds: { string }, p
}) })
previousId = ancestorId previousId = ancestorId
end end
Timer.stop()
end end
local PatchTree = {} local PatchTree = {}
@@ -196,12 +200,16 @@ local PatchTree = {}
-- uses changeListHeaders in node.changeList -- uses changeListHeaders in node.changeList
function PatchTree.build(patch, instanceMap, changeListHeaders) function PatchTree.build(patch, instanceMap, changeListHeaders)
Timer.start("PatchTree.build") Timer.start("PatchTree.build")
local clock = os.clock()
local tree = Tree.new() local tree = Tree.new()
local knownAncestors = {} local knownAncestors = {}
Timer.start("patch.updated") Timer.start("patch.updated")
for _, change in patch.updated do for _, change in patch.updated do
clock = yieldIfNeeded(clock)
local instance = instanceMap.fromIds[change.id] local instance = instanceMap.fromIds[change.id]
if not instance then if not instance then
continue continue
@@ -281,6 +289,8 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
Timer.start("patch.removed") Timer.start("patch.removed")
for _, idOrInstance in patch.removed do for _, idOrInstance in patch.removed do
clock = yieldIfNeeded(clock)
local instance = if Types.RbxId(idOrInstance) then instanceMap.fromIds[idOrInstance] else idOrInstance local instance = if Types.RbxId(idOrInstance) then instanceMap.fromIds[idOrInstance] else idOrInstance
if not instance then if not instance then
-- If we're viewing a past patch, the instance is already removed -- If we're viewing a past patch, the instance is already removed
@@ -325,6 +335,8 @@ function PatchTree.build(patch, instanceMap, changeListHeaders)
Timer.start("patch.added") Timer.start("patch.added")
for id, change in patch.added do for id, change in patch.added do
clock = yieldIfNeeded(clock)
-- Gather ancestors from existing DOM or future additions -- Gather ancestors from existing DOM or future additions
local ancestryIds = {} local ancestryIds = {}
local parentId = change.Parent local parentId = change.Parent

View File

@@ -5,8 +5,6 @@
Patches can come from the server or be generated by the client. 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 Packages = script.Parent.Parent.Parent.Packages
local Log = require(Packages.Log) local Log = require(Packages.Log)
@@ -20,13 +18,6 @@ local reifyInstance, applyDeferredRefs = reify.reifyInstance, reify.applyDeferre
local setProperty = require(script.Parent.setProperty) local setProperty = require(script.Parent.setProperty)
local function applyPatch(instanceMap, patch) 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. -- Tracks any portions of the patch that could not be applied to the DOM.
local unappliedPatch = PatchSet.newEmpty() local unappliedPatch = PatchSet.newEmpty()
@@ -73,9 +64,6 @@ local function applyPatch(instanceMap, patch)
if parentInstance == nil then if parentInstance == nil then
-- This would be peculiar. If you create an instance with no -- This would be peculiar. If you create an instance with no
-- parent, were you supposed to create it at all? -- parent, were you supposed to create it at all?
if historyRecording then
ChangeHistoryService:FinishRecording(historyRecording, Enum.FinishRecordingOperation.Commit)
end
invariant( invariant(
"Cannot add an instance from a patch that has no parent.\nInstance {} with parent {}.\nState: {:#?}", "Cannot add an instance from a patch that has no parent.\nInstance {} with parent {}.\nState: {:#?}",
id, id,
@@ -244,10 +232,6 @@ local function applyPatch(instanceMap, patch)
end end
end end
if historyRecording then
ChangeHistoryService:FinishRecording(historyRecording, Enum.FinishRecordingOperation.Commit)
end
applyDeferredRefs(instanceMap, deferredRefs, unappliedPatch) applyDeferredRefs(instanceMap, deferredRefs, unappliedPatch)
return unappliedPatch return unappliedPatch

View File

@@ -25,6 +25,14 @@ local function trueEquals(a, b): boolean
return true return true
end 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) local typeA, typeB = typeof(a), typeof(b)
-- For tables, try recursive deep equality -- For tables, try recursive deep equality

View File

@@ -5,9 +5,6 @@
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Log = require(Packages.Log)
local Timer = require(Plugin.Timer) local Timer = require(Plugin.Timer)
@@ -22,78 +19,17 @@ function Reconciler.new(instanceMap)
local self = { local self = {
-- Tracks all of the instances known by the reconciler by ID. -- Tracks all of the instances known by the reconciler by ID.
__instanceMap = instanceMap, __instanceMap = instanceMap,
__precommitCallbacks = {},
__postcommitCallbacks = {},
} }
return setmetatable(self, Reconciler) return setmetatable(self, Reconciler)
end 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) function Reconciler:applyPatch(patch)
Timer.start("Reconciler:applyPatch") 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) 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() Timer.stop()
return unappliedPatch return unappliedPatch
end end

View File

@@ -1,11 +1,15 @@
local StudioService = game:GetService("StudioService") local StudioService = game:GetService("StudioService")
local RunService = game:GetService("RunService") 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 Packages = script.Parent.Parent.Packages
local Log = require(Packages.Log) local Log = require(Packages.Log)
local Fmt = require(Packages.Fmt) local Fmt = require(Packages.Fmt)
local t = require(Packages.t) local t = require(Packages.t)
local Promise = require(Packages.Promise) local Promise = require(Packages.Promise)
local Timer = require(script.Parent.Timer)
local ChangeBatcher = require(script.Parent.ChangeBatcher) local ChangeBatcher = require(script.Parent.ChangeBatcher)
local encodePatchUpdate = require(script.Parent.ChangeBatcher.encodePatchUpdate) local encodePatchUpdate = require(script.Parent.ChangeBatcher.encodePatchUpdate)
@@ -44,6 +48,12 @@ local function debugPatch(object)
end) end)
end end
local function attemptReparent(instance, parent)
return pcall(function()
instance.Parent = parent
end)
end
local ServeSession = {} local ServeSession = {}
ServeSession.__index = ServeSession ServeSession.__index = ServeSession
@@ -95,6 +105,9 @@ function ServeSession.new(options)
__changeBatcher = changeBatcher, __changeBatcher = changeBatcher,
__statusChangedCallback = nil, __statusChangedCallback = nil,
__connections = connections, __connections = connections,
__precommitCallbacks = {},
__postcommitCallbacks = {},
__updateLoadingText = function() end,
} }
setmetatable(self, ServeSession) setmetatable(self, ServeSession)
@@ -125,24 +138,68 @@ function ServeSession:setConfirmCallback(callback)
self.__userConfirmCallback = callback self.__userConfirmCallback = callback
end end
function ServeSession:hookPrecommit(callback) function ServeSession:setUpdateLoadingTextCallback(callback)
return self.__reconciler:hookPrecommit(callback) self.__updateLoadingText = callback
end end
function ServeSession:setLoadingText(text: string)
self.__updateLoadingText(text)
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)
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) 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 end
function ServeSession:start() function ServeSession:start()
self:__setStatus(Status.Connecting) self:__setStatus(Status.Connecting)
self:setLoadingText("Connecting to server...")
self.__apiContext self.__apiContext
:connect() :connect()
:andThen(function(serverInfo) :andThen(function(serverInfo)
self:__applyGameAndPlaceId(serverInfo) self:setLoadingText("Loading initial data from server...")
return self:__initialSync(serverInfo):andThen(function() return self:__initialSync(serverInfo):andThen(function()
self:setLoadingText("Starting sync loop...")
self:__setStatus(Status.Connected, serverInfo.projectName) self:__setStatus(Status.Connected, serverInfo.projectName)
self:__applyGameAndPlaceId(serverInfo)
return self:__mainSyncLoop() return self:__mainSyncLoop()
end) end)
@@ -207,6 +264,194 @@ function ServeSession:__onActiveScriptChanged(activeScript)
self.__apiContext:open(scriptId) self.__apiContext:open(scriptId)
end 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]
if not oldInstance then
-- TODO: Why would this happen?
Log.warn("Instance {} not found in InstanceMap during sync replacement", id)
continue
end
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
-- Some children cannot be reparented, such as a TouchTransmitter
local reparentSuccess, reparentError = attemptReparent(child, replacement)
if not reparentSuccess then
Log.warn(
"Could not reparent child {} of instance {} during sync replacement: {}",
child.Name,
oldInstance.Name,
reparentError
)
end
end
-- 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`.
local deleteSuccess, deleteError = attemptReparent(oldInstance, nil)
local replaceSuccess, replaceError = attemptReparent(replacement, oldParent)
if not (deleteSuccess and replaceSuccess) then
Log.warn(
"Could not swap instances {} and {} during sync replacement: {}",
oldInstance.Name,
replacement.Name,
(deleteError or "") .. "\n" .. (replaceError or "")
)
-- We need to revert the failed swap to avoid losing the old instance and children.
for _, child in replacement:GetChildren() do
attemptReparent(child, oldInstance)
end
attemptReparent(oldInstance, oldParent)
-- Our replacement should never have existed in the first place, so we can just destroy it.
replacement:Destroy()
continue
end
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 Settings:get("enableSyncFallback") and not PatchSet.isEmpty(unappliedPatch) then
-- Some changes did not apply, let's try replacing them instead
local addedIdList = PatchSet.addedIdList(unappliedPatch)
local updatedIdList = PatchSet.updatedIdList(unappliedPatch)
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()
-- Update the unapplied patch to reflect which Instances were replaced successfully
if addSuccess then
table.clear(unappliedPatch.added)
PatchSet.assign(unappliedPatch, unappliedAddedRefs)
end
if updateSuccess then
table.clear(unappliedPatch.updated)
PatchSet.assign(unappliedPatch, unappliedUpdateRefs)
end
end
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
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()
if historyRecording then
ChangeHistoryService:FinishRecording(historyRecording, Enum.FinishRecordingOperation.Commit)
end
end
function ServeSession:__initialSync(serverInfo) function ServeSession:__initialSync(serverInfo)
return self.__apiContext:read({ serverInfo.rootInstanceId }):andThen(function(readResponseBody) return self.__apiContext:read({ serverInfo.rootInstanceId }):andThen(function(readResponseBody)
-- Tell the API Context that we're up-to-date with the version of -- Tell the API Context that we're up-to-date with the version of
@@ -216,11 +461,13 @@ function ServeSession:__initialSync(serverInfo)
-- For any instances that line up with the Rojo server's view, start -- For any instances that line up with the Rojo server's view, start
-- tracking them in the reconciler. -- tracking them in the reconciler.
Log.trace("Matching existing Roblox instances to Rojo IDs") Log.trace("Matching existing Roblox instances to Rojo IDs")
self:setLoadingText("Hydrating instance map...")
self.__reconciler:hydrate(readResponseBody.instances, serverInfo.rootInstanceId, game) self.__reconciler:hydrate(readResponseBody.instances, serverInfo.rootInstanceId, game)
-- Calculate the initial patch to apply to the DataModel to catch us -- Calculate the initial patch to apply to the DataModel to catch us
-- up to what Rojo thinks the place should look like. -- up to what Rojo thinks the place should look like.
Log.trace("Computing changes that plugin needs to make to catch up to server...") Log.trace("Computing changes that plugin needs to make to catch up to server...")
self:setLoadingText("Finding differences between server and Studio...")
local success, catchUpPatch = local success, catchUpPatch =
self.__reconciler:diff(readResponseBody.instances, serverInfo.rootInstanceId, game) self.__reconciler:diff(readResponseBody.instances, serverInfo.rootInstanceId, game)
@@ -281,15 +528,7 @@ function ServeSession:__initialSync(serverInfo)
return self.__apiContext:write(inversePatch) return self.__apiContext:write(inversePatch)
elseif userDecision == "Accept" then elseif userDecision == "Accept" then
local unappliedPatch = self.__reconciler:applyPatch(catchUpPatch) self:__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
return Promise.resolve() return Promise.resolve()
else else
return Promise.reject("Invalid user decision: " .. userDecision) return Promise.reject("Invalid user decision: " .. userDecision)
@@ -312,14 +551,7 @@ function ServeSession:__mainSyncLoop()
Log.trace("Serve session {} retrieved {} messages", tostring(self), #messages) Log.trace("Serve session {} retrieved {} messages", tostring(self), #messages)
for _, message in messages do for _, message in messages do
local unappliedPatch = self.__reconciler:applyPatch(message) self:__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
end end
end) end)
:await() :await()

View File

@@ -12,12 +12,15 @@ local Roact = require(Packages.Roact)
local defaultSettings = { local defaultSettings = {
openScriptsExternally = false, openScriptsExternally = false,
twoWaySync = false, twoWaySync = false,
autoReconnect = false,
showNotifications = true, showNotifications = true,
syncReminder = true, enableSyncFallback = true,
syncReminderMode = "Notify" :: "None" | "Notify" | "Fullscreen",
syncReminderPolling = true,
checkForUpdates = true, checkForUpdates = true,
checkForPrereleases = false, checkForPrereleases = false,
autoConnectPlaytestServer = false, autoConnectPlaytestServer = false,
confirmationBehavior = "Initial", confirmationBehavior = "Initial" :: "Never" | "Initial" | "Large Changes" | "Unlisted PlaceId",
largeChangesConfirmationThreshold = 5, largeChangesConfirmationThreshold = 5,
playSounds = true, playSounds = true,
typecheckingEnabled = false, typecheckingEnabled = false,
@@ -108,4 +111,14 @@ function Settings:getBinding(name)
return bind return bind
end 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 return Settings

View File

@@ -55,6 +55,16 @@ local ApiSubscribeResponse = t.interface({
messages = t.array(ApiSubscribeMessage), 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({ local ApiError = t.interface({
kind = t.union(t.literal("NotFound"), t.literal("BadRequest"), t.literal("InternalError")), kind = t.union(t.literal("NotFound"), t.literal("BadRequest"), t.literal("InternalError")),
details = t.string, details = t.string,
@@ -82,6 +92,8 @@ return strict("Types", {
ApiInstanceUpdate = ApiInstanceUpdate, ApiInstanceUpdate = ApiInstanceUpdate,
ApiInstanceMetadata = ApiInstanceMetadata, ApiInstanceMetadata = ApiInstanceMetadata,
ApiSubscribeMessage = ApiSubscribeMessage, ApiSubscribeMessage = ApiSubscribeMessage,
ApiSerializeResponse = ApiSerializeResponse,
ApiRefPatchResponse = ApiRefPatchResponse,
ApiValue = ApiValue, ApiValue = ApiValue,
RbxId = RbxId, 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 Http = require(Packages.Http)
local Promise = require(Packages.Promise) 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) local function compare(a, b)
if a > b then if a > b then
@@ -88,14 +102,26 @@ function Version.display(version)
return output return output
end 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: { function Version.retrieveLatestCompatible(options: {
version: { number }, version: { number },
includePrereleases: boolean?, includePrereleases: boolean?,
}): { }): LatestReleaseInfo?
version: { number }, if Version._cachedLatestCompatible and os.clock() - Version._cachedLatestCompatible.timestamp < 60 * 60 * 24 then
prerelease: boolean, Log.debug("Using cached latest compatible version")
publishedUnixTimestamp: number, 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") local success, releases = Http.get("https://api.github.com/repos/rojo-rbx/rojo/releases?per_page=10")
:andThen(function(response) :andThen(function(response)
if response.code >= 400 then if response.code >= 400 then
@@ -114,7 +140,7 @@ function Version.retrieveLatestCompatible(options: {
end end
-- Iterate through releases, looking for the latest compatible version -- Iterate through releases, looking for the latest compatible version
local latestCompatible = nil local latestCompatible: LatestReleaseInfo? = nil
for _, release in releases do for _, release in releases do
-- Skip prereleases if they are not requested -- Skip prereleases if they are not requested
if (not options.includePrereleases) and release.prerelease then 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 -- 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 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 return nil
end end
-- Cache the latest compatible version
Version._cachedLatestCompatible = {
value = latestCompatible,
timestamp = os.clock(),
}
return latestCompatible return latestCompatible
end 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 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] [config]
unused_variable = { allow_unused_self = true } 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::process::{Command, Stdio};
use std::str::FromStr; use std::str::FromStr;
use std::{
collections::VecDeque,
path::{Path, PathBuf},
};
use std::{
ffi::OsStr,
io::{self, Write},
};
use anyhow::{bail, format_err}; use anyhow::{bail, format_err};
use clap::Parser; use clap::Parser;
use fs_err as fs; use fs_err as fs;
use fs_err::OpenOptions; use fs_err::OpenOptions;
use memofs::{InMemoryFs, Vfs, VfsSnapshot};
use super::resolve_path; use super::resolve_path;
static MODEL_PROJECT: &str = const GIT_IGNORE_PLACEHOLDER: &str = "gitignore.txt";
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");
static PLACE_PROJECT: &str = static TEMPLATE_BINCODE: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/templates.bincode"));
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");
/// Initializes a new Rojo project. /// 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)] #[derive(Debug, Parser)]
pub struct InitCommand { pub struct InitCommand {
/// Path to the place to create the project. Defaults to the current directory. /// Path to the place to create the project. Defaults to the current directory.
#[clap(default_value = "")] #[clap(default_value = "")]
pub path: PathBuf, 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")] #[clap(long, default_value = "place")]
pub kind: InitKind, pub kind: InitKind,
/// Skips the initialization of a git repository.
#[clap(long)]
pub skip_git: bool,
} }
impl InitCommand { impl InitCommand {
pub fn run(self) -> anyhow::Result<()> { pub fn run(self) -> anyhow::Result<()> {
let template = self.kind.template();
let base_path = resolve_path(&self.path); let base_path = resolve_path(&self.path);
fs::create_dir_all(&base_path)?; fs::create_dir_all(&base_path)?;
@@ -53,10 +57,51 @@ impl InitCommand {
name: project_name.to_owned(), name: project_name.to_owned(),
}; };
match self.kind { println!(
InitKind::Place => init_place(&base_path, project_params)?, "Creating new {:?} project '{}'",
InitKind::Model => init_model(&base_path, project_params)?, self.kind, project_params.name
InitKind::Plugin => init_plugin(&base_path, project_params)?, );
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."); println!("Created project successfully.");
@@ -78,6 +123,32 @@ pub enum InitKind {
Plugin, 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 { impl FromStr for InitKind {
type Err = anyhow::Error; 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. /// Contains parameters used in templates to create a project.
struct ProjectParams { struct ProjectParams {
name: String, 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. /// 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 /// 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(()) 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::{ use std::{
borrow::Cow,
io::{BufWriter, Write}, io::{BufWriter, Write},
mem::forget, mem::forget,
path::{Path, PathBuf}, path::{self, Path, PathBuf},
}; };
use clap::Parser; use clap::Parser;
@@ -20,6 +21,7 @@ use crate::{
use super::resolve_path; use super::resolve_path;
const PATH_STRIP_FAILED_ERR: &str = "Failed to create relative paths for project file!"; 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. /// Representation of a node in the generated sourcemap tree.
#[derive(Serialize)] #[derive(Serialize)]
@@ -28,8 +30,11 @@ struct SourcemapNode<'a> {
name: &'a str, name: &'a str,
class_name: Ustr, class_name: Ustr,
#[serde(skip_serializing_if = "Vec::is_empty")] #[serde(
file_paths: Vec<PathBuf>, 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")] #[serde(skip_serializing_if = "Vec::is_empty")]
children: Vec<SourcemapNode<'a>>, children: Vec<SourcemapNode<'a>>,
@@ -57,6 +62,10 @@ pub struct SourcemapCommand {
/// Whether to automatically recreate a snapshot when any input files change. /// Whether to automatically recreate a snapshot when any input files change.
#[clap(long)] #[clap(long)]
pub watch: bool, pub watch: bool,
/// Whether the sourcemap should use absolute paths instead of relative paths.
#[clap(long)]
pub absolute: bool,
} }
impl SourcemapCommand { impl SourcemapCommand {
@@ -83,7 +92,7 @@ impl SourcemapCommand {
.build_global() .build_global()
.unwrap(); .unwrap();
write_sourcemap(&session, self.output.as_deref(), filter)?; write_sourcemap(&session, self.output.as_deref(), filter, self.absolute)?;
if self.watch { if self.watch {
let rt = Runtime::new().unwrap(); let rt = Runtime::new().unwrap();
@@ -94,7 +103,7 @@ impl SourcemapCommand {
cursor = new_cursor; cursor = new_cursor;
if patch_set_affects_sourcemap(&session, &patch_set, filter) { 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, referent: Ref,
project_dir: &Path, project_dir: &Path,
filter: fn(&InstanceWithMeta) -> bool, filter: fn(&InstanceWithMeta) -> bool,
use_absolute_paths: bool,
) -> Option<SourcemapNode<'a>> { ) -> Option<SourcemapNode<'a>> {
let instance = tree.get_instance(referent).expect("instance did not exist"); let instance = tree.get_instance(referent).expect("instance did not exist");
let children: Vec<_> = instance let children: Vec<_> = instance
.children() .children()
.par_iter() .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(); .collect();
// If this object has no children and doesn't pass the filter, it doesn't // 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() .iter()
// Not all paths listed as relevant are guaranteed to exist. // Not all paths listed as relevant are guaranteed to exist.
.filter(|path| path.is_file()) .filter(|path| path.is_file())
.map(|path| path.strip_prefix(project_dir).expect(PATH_STRIP_FAILED_ERR)) .map(|path| path.as_path());
.map(|path| path.to_path_buf())
.collect(); 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 { Some(SourcemapNode {
name: instance.name(), name: instance.name(),
class_name: instance.class_name(), class_name: instance.class_name(),
file_paths, file_paths: output_file_paths,
children, children,
}) })
} }
@@ -197,10 +225,17 @@ fn write_sourcemap(
session: &ServeSession, session: &ServeSession,
output: Option<&Path>, output: Option<&Path>,
filter: fn(&InstanceWithMeta) -> bool, filter: fn(&InstanceWithMeta) -> bool,
use_absolute_paths: bool,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
let tree = session.tree(); 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 { if let Some(output_path) = output {
let mut file = BufWriter::new(File::create(output_path)?); let mut file = BufWriter::new(File::create(output_path)?);

View File

@@ -1,7 +1,6 @@
use std::path::PathBuf; use std::path::PathBuf;
use std::str::FromStr;
use anyhow::{bail, format_err, Context}; use anyhow::{bail, Context};
use clap::Parser; use clap::Parser;
use memofs::Vfs; use memofs::Vfs;
use reqwest::{ 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<()> { fn do_upload(buffer: Vec<u8>, asset_id: u64, cookie: &str) -> anyhow::Result<()> {
let url = format!( let url = format!(
"https://data.roblox.com/Data/Upload.ashx?assetid={}", "https://data.roblox.com/Data/Upload.ashx?assetid={}",

View File

@@ -43,8 +43,8 @@ impl Serialize for Glob {
impl<'de> Deserialize<'de> for Glob { impl<'de> Deserialize<'de> for Glob {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> { fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let glob = <&str as Deserialize>::deserialize(deserializer)?; let glob = String::deserialize(deserializer)?;
Glob::new(glob).map_err(D::Error::custom) Glob::new(&glob).map_err(D::Error::custom)
} }
} }

313
src/json.rs Normal file
View File

@@ -0,0 +1,313 @@
//! Utilities for parsing JSON with comments (JSONC) and deserializing to Rust types.
//!
//! This module provides convenient wrappers around `jsonc_parser` and `serde_json`
//! to reduce boilerplate and improve ergonomics when working with JSONC files.
use anyhow::Context as _;
use serde::de::DeserializeOwned;
/// Parse JSONC text into a `serde_json::Value`.
///
/// This handles the common pattern of calling `jsonc_parser::parse_to_serde_value`
/// and unwrapping the `Option` with a clear error message.
///
/// # Errors
///
/// Returns an error if:
/// - The text is not valid JSONC
/// - The text contains no JSON value
pub fn parse_value(text: &str) -> anyhow::Result<serde_json::Value> {
jsonc_parser::parse_to_serde_value(text, &Default::default())
.context("Failed to parse JSONC")?
.ok_or_else(|| anyhow::anyhow!("File contains no JSON value"))
}
/// Parse JSONC text into a `serde_json::Value` with a custom context message.
///
/// This is useful when you want to provide a specific error message that includes
/// additional information like the file path.
///
/// # Errors
///
/// Returns an error if:
/// - The text is not valid JSONC
/// - The text contains no JSON value
pub fn parse_value_with_context(
text: &str,
context: impl Fn() -> String,
) -> anyhow::Result<serde_json::Value> {
jsonc_parser::parse_to_serde_value(text, &Default::default())
.with_context(|| format!("{}: JSONC parse error", context()))?
.ok_or_else(|| anyhow::anyhow!("{}: File contains no JSON value", context()))
}
/// Parse JSONC text and deserialize it into a specific type.
///
/// This combines parsing JSONC and deserializing into a single operation,
/// eliminating the need to manually chain `parse_to_serde_value` and `from_value`.
///
/// # Errors
///
/// Returns an error if:
/// - The text is not valid JSONC
/// - The text contains no JSON value
/// - The value cannot be deserialized into type `T`
pub fn from_str<T: DeserializeOwned>(text: &str) -> anyhow::Result<T> {
let value = parse_value(text)?;
serde_json::from_value(value).context("Failed to deserialize JSON")
}
/// Parse JSONC text and deserialize it into a specific type with a custom context message.
///
/// This is useful when you want to provide a specific error message that includes
/// additional information like the file path.
///
/// # Errors
///
/// Returns an error if:
/// - The text is not valid JSONC
/// - The text contains no JSON value
/// - The value cannot be deserialized into type `T`
pub fn from_str_with_context<T: DeserializeOwned>(
text: &str,
context: impl Fn() -> String,
) -> anyhow::Result<T> {
let value = parse_value_with_context(text, &context)?;
serde_json::from_value(value).with_context(|| format!("{}: Invalid JSON structure", context()))
}
/// Parse JSONC bytes into a `serde_json::Value` with a custom context message.
///
/// This handles UTF-8 conversion and JSONC parsing in one step.
///
/// # Errors
///
/// Returns an error if:
/// - The bytes are not valid UTF-8
/// - The text is not valid JSONC
/// - The text contains no JSON value
pub fn parse_value_from_slice_with_context(
slice: &[u8],
context: impl Fn() -> String,
) -> anyhow::Result<serde_json::Value> {
let text = std::str::from_utf8(slice)
.with_context(|| format!("{}: File is not valid UTF-8", context()))?;
parse_value_with_context(text, context)
}
/// Parse JSONC bytes and deserialize it into a specific type.
///
/// This handles UTF-8 conversion, JSONC parsing, and deserialization in one step.
///
/// # Errors
///
/// Returns an error if:
/// - The bytes are not valid UTF-8
/// - The text is not valid JSONC
/// - The text contains no JSON value
/// - The value cannot be deserialized into type `T`
pub fn from_slice<T: DeserializeOwned>(slice: &[u8]) -> anyhow::Result<T> {
let text = std::str::from_utf8(slice).context("File is not valid UTF-8")?;
from_str(text)
}
/// Parse JSONC bytes and deserialize it into a specific type with a custom context message.
///
/// This handles UTF-8 conversion, JSONC parsing, and deserialization in one step.
///
/// # Errors
///
/// Returns an error if:
/// - The bytes are not valid UTF-8
/// - The text is not valid JSONC
/// - The text contains no JSON value
/// - The value cannot be deserialized into type `T`
pub fn from_slice_with_context<T: DeserializeOwned>(
slice: &[u8],
context: impl Fn() -> String,
) -> anyhow::Result<T> {
let text = std::str::from_utf8(slice)
.with_context(|| format!("{}: File is not valid UTF-8", context()))?;
from_str_with_context(text, context)
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Deserialize;
#[test]
fn test_parse_value() {
let value = parse_value(r#"{"foo": "bar"}"#).unwrap();
assert_eq!(value["foo"], "bar");
}
#[test]
fn test_parse_value_with_comments() {
let value = parse_value(
r#"{
// This is a comment
"foo": "bar" // Inline comment
}"#,
)
.unwrap();
assert_eq!(value["foo"], "bar");
}
#[test]
fn test_parse_value_with_trailing_comma() {
let value = parse_value(
r#"{
"foo": "bar",
"baz": 123,
}"#,
)
.unwrap();
assert_eq!(value["foo"], "bar");
assert_eq!(value["baz"], 123);
}
#[test]
fn test_parse_value_empty() {
let err = parse_value("").unwrap_err();
assert!(err.to_string().contains("no JSON value"));
}
#[test]
fn test_parse_value_invalid() {
let err = parse_value("{invalid}").unwrap_err();
assert!(err.to_string().contains("parse"));
}
#[test]
fn test_parse_value_with_context() {
let err = parse_value_with_context("{invalid}", || "test.json".to_string()).unwrap_err();
assert!(err.to_string().contains("test.json"));
assert!(err.to_string().contains("parse"));
}
#[derive(Debug, Deserialize, PartialEq)]
struct TestStruct {
foo: String,
bar: i32,
}
#[test]
fn test_from_str() {
let result: TestStruct = from_str(r#"{"foo": "hello", "bar": 42}"#).unwrap();
assert_eq!(
result,
TestStruct {
foo: "hello".to_string(),
bar: 42
}
);
}
#[test]
fn test_from_str_with_comments() {
let result: TestStruct = from_str(
r#"{
// Comment
"foo": "hello",
"bar": 42, // Trailing comma is fine
}"#,
)
.unwrap();
assert_eq!(
result,
TestStruct {
foo: "hello".to_string(),
bar: 42
}
);
}
#[test]
fn test_from_str_invalid_type() {
let err = from_str::<TestStruct>(r#"{"foo": "hello"}"#).unwrap_err();
assert!(err.to_string().contains("deserialize"));
}
#[test]
fn test_from_str_with_context() {
let err = from_str_with_context::<TestStruct>(r#"{"foo": "hello"}"#, || {
"config.json".to_string()
})
.unwrap_err();
assert!(err.to_string().contains("config.json"));
assert!(err.to_string().contains("Invalid JSON structure"));
}
#[test]
fn test_parse_value_from_slice_with_context() {
let err = parse_value_from_slice_with_context(b"{invalid}", || "test.json".to_string())
.unwrap_err();
assert!(err.to_string().contains("test.json"));
assert!(err.to_string().contains("parse"));
}
#[test]
fn test_parse_value_from_slice_with_context_invalid_utf8() {
let err = parse_value_from_slice_with_context(&[0xFF, 0xFF], || "test.json".to_string())
.unwrap_err();
assert!(err.to_string().contains("test.json"));
assert!(err.to_string().contains("UTF-8"));
}
#[test]
fn test_from_slice() {
let result: TestStruct = from_slice(br#"{"foo": "hello", "bar": 42}"#).unwrap();
assert_eq!(
result,
TestStruct {
foo: "hello".to_string(),
bar: 42
}
);
}
#[test]
fn test_from_slice_with_comments() {
let result: TestStruct = from_slice(
br#"{
// Comment
"foo": "hello",
"bar": 42, // Trailing comma is fine
}"#,
)
.unwrap();
assert_eq!(
result,
TestStruct {
foo: "hello".to_string(),
bar: 42
}
);
}
#[test]
fn test_from_slice_invalid_utf8() {
let err = from_slice::<TestStruct>(&[0xFF, 0xFF]).unwrap_err();
assert!(err.to_string().contains("UTF-8"));
}
#[test]
fn test_from_slice_with_context() {
let err = from_slice_with_context::<TestStruct>(br#"{"foo": "hello"}"#, || {
"config.json".to_string()
})
.unwrap_err();
assert!(err.to_string().contains("config.json"));
assert!(err.to_string().contains("Invalid JSON structure"));
}
#[test]
fn test_from_slice_with_context_invalid_utf8() {
let err =
from_slice_with_context::<TestStruct>(&[0xFF, 0xFF], || "config.json".to_string())
.unwrap_err();
assert!(err.to_string().contains("config.json"));
assert!(err.to_string().contains("UTF-8"));
}
}

View File

@@ -10,6 +10,7 @@ mod tree_view;
mod auth_cookie; mod auth_cookie;
mod change_processor; mod change_processor;
mod glob; mod glob;
mod json;
mod lua_ast; mod lua_ast;
mod message_queue; mod message_queue;
mod multimap; mod multimap;

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!("This is probably a Rojo bug.");
log::error!(""); log::error!("");
log::error!( log::error!(

View File

@@ -11,7 +11,7 @@ use rbx_dom_weak::{Ustr, UstrMap};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use thiserror::Error; use thiserror::Error;
use crate::{glob::Glob, resolution::UnresolvedValue, snapshot::SyncRule}; use crate::{glob::Glob, json, resolution::UnresolvedValue, snapshot::SyncRule};
static PROJECT_FILENAME: &str = "default.project.json"; static PROJECT_FILENAME: &str = "default.project.json";
@@ -214,8 +214,11 @@ impl Project {
project_file_location: PathBuf, project_file_location: PathBuf,
fallback_name: Option<&str>, fallback_name: Option<&str>,
) -> Result<Self, Error> { ) -> Result<Self, Error> {
let mut project: Self = serde_json::from_slice(contents).map_err(|source| Error::Json { let mut project: Self = json::from_slice(contents).map_err(|e| Error::Json {
source, source: serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
e.to_string(),
)),
path: project_file_location.clone(), path: project_file_location.clone(),
})?; })?;
project.file_location = project_file_location; project.file_location = project_file_location;
@@ -399,13 +402,13 @@ mod test {
#[test] #[test]
fn path_node_required() { fn path_node_required() {
let path_node: PathNode = serde_json::from_str(r#""src""#).unwrap(); let path_node: PathNode = json::from_str(r#""src""#).unwrap();
assert_eq!(path_node, PathNode::Required(PathBuf::from("src"))); assert_eq!(path_node, PathNode::Required(PathBuf::from("src")));
} }
#[test] #[test]
fn path_node_optional() { fn path_node_optional() {
let path_node: PathNode = serde_json::from_str(r#"{ "optional": "src" }"#).unwrap(); let path_node: PathNode = json::from_str(r#"{ "optional": "src" }"#).unwrap();
assert_eq!( assert_eq!(
path_node, path_node,
PathNode::Optional(OptionalPathNode::new(PathBuf::from("src"))) PathNode::Optional(OptionalPathNode::new(PathBuf::from("src")))
@@ -414,7 +417,7 @@ mod test {
#[test] #[test]
fn project_node_required() { fn project_node_required() {
let project_node: ProjectNode = serde_json::from_str( let project_node: ProjectNode = json::from_str(
r#"{ r#"{
"$path": "src" "$path": "src"
}"#, }"#,
@@ -429,7 +432,7 @@ mod test {
#[test] #[test]
fn project_node_optional() { fn project_node_optional() {
let project_node: ProjectNode = serde_json::from_str( let project_node: ProjectNode = json::from_str(
r#"{ r#"{
"$path": { "optional": "src" } "$path": { "optional": "src" }
}"#, }"#,
@@ -446,7 +449,7 @@ mod test {
#[test] #[test]
fn project_node_none() { fn project_node_none() {
let project_node: ProjectNode = serde_json::from_str( let project_node: ProjectNode = json::from_str(
r#"{ r#"{
"$className": "Folder" "$className": "Folder"
}"#, }"#,
@@ -458,7 +461,7 @@ mod test {
#[test] #[test]
fn project_node_optional_serialize_absolute() { fn project_node_optional_serialize_absolute() {
let project_node: ProjectNode = serde_json::from_str( let project_node: ProjectNode = json::from_str(
r#"{ r#"{
"$path": { "optional": "..\\src" } "$path": { "optional": "..\\src" }
}"#, }"#,
@@ -471,7 +474,7 @@ mod test {
#[test] #[test]
fn project_node_optional_serialize_absolute_no_change() { fn project_node_optional_serialize_absolute_no_change() {
let project_node: ProjectNode = serde_json::from_str( let project_node: ProjectNode = json::from_str(
r#"{ r#"{
"$path": { "optional": "../src" } "$path": { "optional": "../src" }
}"#, }"#,
@@ -484,7 +487,7 @@ mod test {
#[test] #[test]
fn project_node_optional_serialize_optional() { fn project_node_optional_serialize_optional() {
let project_node: ProjectNode = serde_json::from_str( let project_node: ProjectNode = json::from_str(
r#"{ r#"{
"$path": "..\\src" "$path": "..\\src"
}"#, }"#,
@@ -494,4 +497,57 @@ mod test {
let serialized = serde_json::to_string(&project_node).unwrap(); let serialized = serde_json::to_string(&project_node).unwrap();
assert_eq!(serialized, r#"{"$path":"../src"}"#); assert_eq!(serialized, r#"{"$path":"../src"}"#);
} }
#[test]
fn project_with_jsonc_features() {
// Test that JSONC features (comments and trailing commas) are properly handled
let project_json = r#"{
// This is a single-line comment
"name": "TestProject",
/* This is a
multi-line comment */
"tree": {
"$path": "src", // Comment after value
},
"servePort": 34567,
"emitLegacyScripts": false,
// Test glob parsing with comments
"globIgnorePaths": [
"**/*.spec.lua", // Ignore test files
"**/*.test.lua",
],
"syncRules": [
{
"pattern": "*.data.json",
"use": "json", // Trailing comma in object
},
{
"pattern": "*.module.lua",
"use": "moduleScript",
}, // Trailing comma in array
], // Another trailing comma
}"#;
let project = Project::load_from_slice(
project_json.as_bytes(),
PathBuf::from("/test/default.project.json"),
None,
)
.expect("Failed to parse project with JSONC features");
// Verify the parsed values
assert_eq!(project.name, Some("TestProject".to_string()));
assert_eq!(project.serve_port, Some(34567));
assert_eq!(project.emit_legacy_scripts, Some(false));
// Verify glob_ignore_paths were parsed correctly
assert_eq!(project.glob_ignore_paths.len(), 2);
assert!(project.glob_ignore_paths[0].is_match("test/foo.spec.lua"));
assert!(project.glob_ignore_paths[1].is_match("test/bar.test.lua"));
// Verify sync_rules were parsed correctly
assert_eq!(project.sync_rules.len(), 2);
assert!(project.sync_rules[0].include.is_match("data.data.json"));
assert!(project.sync_rules[1].include.is_match("init.module.lua"));
}
} }

View File

@@ -62,7 +62,7 @@ impl AmbiguousValue {
match &property.data_type { match &property.data_type {
DataType::Enum(enum_name) => { 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(|| { let enum_descriptor = database.enums.get(enum_name).ok_or_else(|| {
format_err!("Unknown enum {}. This is a Rojo bug!", enum_name) format_err!("Unknown enum {}. This is a Rojo bug!", enum_name)
@@ -203,7 +203,7 @@ fn find_descriptor(
class_name: &str, class_name: &str,
prop_name: &str, prop_name: &str,
) -> Option<&'static PropertyDescriptor<'static>> { ) -> Option<&'static PropertyDescriptor<'static>> {
let database = rbx_reflection_database::get(); let database = rbx_reflection_database::get().unwrap();
let mut current_class_name = class_name; let mut current_class_name = class_name;
loop { loop {
@@ -248,14 +248,15 @@ fn nonexhaustive_list(values: &[&str]) -> String {
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;
use crate::json;
fn resolve(class: &str, prop: &str, json_value: &str) -> Variant { fn resolve(class: &str, prop: &str, json_value: &str) -> Variant {
let unresolved: UnresolvedValue = serde_json::from_str(json_value).unwrap(); let unresolved: UnresolvedValue = json::from_str(json_value).unwrap();
unresolved.resolve(class, prop).unwrap() unresolved.resolve(class, prop).unwrap()
} }
fn resolve_unambiguous(json_value: &str) -> Variant { fn resolve_unambiguous(json_value: &str) -> Variant {
let unresolved: UnresolvedValue = serde_json::from_str(json_value).unwrap(); let unresolved: UnresolvedValue = json::from_str(json_value).unwrap();
unresolved.resolve_unambiguous().unwrap() unresolved.resolve_unambiguous().unwrap()
} }

View File

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

View File

@@ -73,7 +73,7 @@ impl RojoTree {
self.inner.root_ref() 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) { if let Some(instance) = self.inner.get_by_ref(id) {
let metadata = self.metadata_map.get(&id).unwrap(); 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) { if let Some(instance) = self.inner.get_by_ref_mut(id) {
let metadata = self.metadata_map.get_mut(&id).unwrap(); let metadata = self.metadata_map.get_mut(&id).unwrap();

View File

@@ -1,10 +1,10 @@
use std::path::Path; use std::path::Path;
use anyhow::Context;
use memofs::{IoResultExt, Vfs}; use memofs::{IoResultExt, Vfs};
use rbx_dom_weak::ustr; use rbx_dom_weak::ustr;
use crate::{ use crate::{
json,
lua_ast::{Expression, Statement}, lua_ast::{Expression, Statement},
snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot}, snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot},
}; };
@@ -19,8 +19,9 @@ pub fn snapshot_json(
) -> anyhow::Result<Option<InstanceSnapshot>> { ) -> anyhow::Result<Option<InstanceSnapshot>> {
let contents = vfs.read(path)?; let contents = vfs.read(path)?;
let value: serde_json::Value = serde_json::from_slice(&contents) let value = json::parse_value_from_slice_with_context(&contents, || {
.with_context(|| format!("File contains malformed JSON: {}", path.display()))?; format!("File contains malformed JSON: {}", path.display())
})?;
let as_lua = json_to_lua(value).to_string(); let as_lua = json_to_lua(value).to_string();

View File

@@ -9,6 +9,7 @@ use rbx_dom_weak::{
use serde::Deserialize; use serde::Deserialize;
use crate::{ use crate::{
json,
resolution::UnresolvedValue, resolution::UnresolvedValue,
snapshot::{InstanceContext, InstanceSnapshot}, snapshot::{InstanceContext, InstanceSnapshot},
RojoRef, RojoRef,
@@ -28,8 +29,9 @@ pub fn snapshot_json_model(
return Ok(None); return Ok(None);
} }
let mut instance: JsonModel = serde_json::from_str(contents_str) let mut instance: JsonModel = json::from_str_with_context(contents_str, || {
.with_context(|| format!("File is not a valid JSON model: {}", path.display()))?; format!("File is not a valid JSON model: {}", path.display())
})?;
if let Some(top_level_name) = &instance.name { if let Some(top_level_name) = &instance.name {
let new_name = format!("{}.model.json", top_level_name); let new_name = format!("{}.model.json", top_level_name);

View File

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

View File

@@ -4,7 +4,7 @@ use anyhow::{format_err, Context};
use rbx_dom_weak::{types::Attributes, Ustr, UstrMap}; use rbx_dom_weak::{types::Attributes, Ustr, UstrMap};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::{resolution::UnresolvedValue, snapshot::InstanceSnapshot, RojoRef}; use crate::{json, resolution::UnresolvedValue, snapshot::InstanceSnapshot, RojoRef};
/// Represents metadata in a sibling file with the same basename. /// Represents metadata in a sibling file with the same basename.
/// ///
@@ -34,7 +34,7 @@ pub struct AdjacentMetadata {
impl AdjacentMetadata { impl AdjacentMetadata {
pub fn from_slice(slice: &[u8], path: PathBuf) -> anyhow::Result<Self> { pub fn from_slice(slice: &[u8], path: PathBuf) -> anyhow::Result<Self> {
let mut meta: Self = serde_json::from_slice(slice).with_context(|| { let mut meta: Self = json::from_slice_with_context(slice, || {
format!( format!(
"File contained malformed .meta.json data: {}", "File contained malformed .meta.json data: {}",
path.display() path.display()
@@ -131,7 +131,7 @@ pub struct DirectoryMetadata {
impl DirectoryMetadata { impl DirectoryMetadata {
pub fn from_slice(slice: &[u8], path: PathBuf) -> anyhow::Result<Self> { pub fn from_slice(slice: &[u8], path: PathBuf) -> anyhow::Result<Self> {
let mut meta: Self = serde_json::from_slice(slice).with_context(|| { let mut meta: Self = json::from_slice_with_context(slice, || {
format!( format!(
"File contained malformed init.meta.json data: {}", "File contained malformed init.meta.json data: {}",
path.display() path.display()

View File

@@ -17,6 +17,7 @@ mod rbxmx;
mod toml; mod toml;
mod txt; mod txt;
mod util; mod util;
mod yaml;
use std::{ use std::{
path::{Path, PathBuf}, path::{Path, PathBuf},
@@ -41,6 +42,7 @@ use self::{
rbxmx::snapshot_rbxmx, rbxmx::snapshot_rbxmx,
toml::snapshot_toml, toml::snapshot_toml,
txt::snapshot_txt, txt::snapshot_txt,
yaml::snapshot_yaml,
}; };
pub use self::{project::snapshot_project_node, util::emit_legacy_scripts_default}; pub use self::{project::snapshot_project_node, util::emit_legacy_scripts_default};
@@ -212,6 +214,7 @@ pub enum Middleware {
Rbxmx, Rbxmx,
Toml, Toml,
Text, Text,
Yaml,
Ignore, Ignore,
} }
@@ -250,6 +253,7 @@ impl Middleware {
Self::Rbxmx => snapshot_rbxmx(context, vfs, path, name), Self::Rbxmx => snapshot_rbxmx(context, vfs, path, name),
Self::Toml => snapshot_toml(context, vfs, path, name), Self::Toml => snapshot_toml(context, vfs, path, name),
Self::Text => snapshot_txt(context, vfs, path, name), Self::Text => snapshot_txt(context, vfs, path, name),
Self::Yaml => snapshot_yaml(context, vfs, path, name),
Self::Ignore => Ok(None), Self::Ignore => Ok(None),
} }
} }
@@ -315,6 +319,7 @@ pub fn default_sync_rules() -> &'static [SyncRule] {
sync_rule!("*.txt", Text), sync_rule!("*.txt", Text),
sync_rule!("*.rbxmx", Rbxmx), sync_rule!("*.rbxmx", Rbxmx),
sync_rule!("*.rbxm", Rbxm), 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( metadata.instigating_source = Some(InstigatingSource::ProjectNode(
project_path.to_path_buf(), project_path.to_path_buf(),
instance_name.to_string(), instance_name.to_string(),
node.clone(), Box::new(node.clone()),
parent_class.map(|name| name.to_owned()), 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 // Members of DataModel with names that match known services are
// probably supposed to be those services. // 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) { if descriptor.tags.contains(&ClassTag::Service) {
return Some(ustr(name)); 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,13 +1,23 @@
//! Defines Rojo's HTTP API, all under /api. These endpoints generally return //! Defines Rojo's HTTP API, all under /api. These endpoints generally return
//! JSON. //! 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 hyper::{body, Body, Method, Request, Response, StatusCode};
use opener::OpenError; use opener::OpenError;
use rbx_dom_weak::types::Ref; use rbx_dom_weak::{
types::{Ref, Variant},
InstanceBuilder, UstrMap, WeakDom,
};
use crate::{ use crate::{
json,
serve_session::ServeSession, serve_session::ServeSession,
snapshot::{InstanceWithMeta, PatchSet, PatchUpdate}, snapshot::{InstanceWithMeta, PatchSet, PatchUpdate},
web::{ web::{
@@ -18,6 +28,7 @@ use crate::{
}, },
util::{json, json_ok}, util::{json, json_ok},
}, },
web_api::{BufferEncode, InstanceUpdate, RefPatchResponse, SerializeResponse},
}; };
pub async fn call(serve_session: Arc<ServeSession>, request: Request<Body>) -> Response<Body> { pub async fn call(serve_session: Arc<ServeSession>, request: Request<Body>) -> Response<Body> {
@@ -31,10 +42,16 @@ pub async fn call(serve_session: Arc<ServeSession>, request: Request<Body>) -> R
(&Method::GET, path) if path.starts_with("/api/subscribe/") => { (&Method::GET, path) if path.starts_with("/api/subscribe/") => {
service.handle_api_subscribe(request).await 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/") => { (&Method::POST, path) if path.starts_with("/api/open/") => {
service.handle_api_open(request).await service.handle_api_open(request).await
} }
(&Method::POST, "/api/write") => service.handle_api_write(request).await, (&Method::POST, "/api/write") => service.handle_api_write(request).await,
(_method, path) => json( (_method, path) => json(
@@ -123,7 +140,7 @@ impl ApiService {
let body = body::to_bytes(request.into_body()).await.unwrap(); let body = body::to_bytes(request.into_body()).await.unwrap();
let request: WriteRequest = match serde_json::from_slice(&body) { let request: WriteRequest = match json::from_slice(&body) {
Ok(request) => request, Ok(request) => request,
Err(err) => { Err(err) => {
return json( return json(
@@ -201,6 +218,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. /// 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> { async fn handle_api_open(&self, request: Request<Body>) -> Response<Body> {
let argument = &request.uri().path()["/api/open/".len()..]; let argument = &request.uri().path()["/api/open/".len()..];
@@ -306,3 +443,17 @@ fn pick_script_path(instance: InstanceWithMeta<'_>) -> Option<PathBuf> {
}) })
.map(|path| path.to_owned()) .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, 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 /// General response type returned from all Rojo routes
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]

View File

@@ -1,4 +1,5 @@
use std::{ use std::{
fmt::Write as _,
fs, fs,
path::{Path, PathBuf}, path::{Path, PathBuf},
process::Command, process::Command,
@@ -11,7 +12,7 @@ use rbx_dom_weak::types::Ref;
use tempfile::{tempdir, TempDir}; 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 rojo_insta_ext::RedactionMap;
use crate::rojo_test::io_util::{ use crate::rojo_test::io_util::{
@@ -156,14 +157,20 @@ impl TestServeSession {
let url = format!("http://localhost:{}/api/rojo", self.port); let url = format!("http://localhost:{}/api/rojo", self.port);
let body = reqwest::blocking::get(url)?.text()?; let body = reqwest::blocking::get(url)?.text()?;
Ok(serde_json::from_str(&body).expect("Server returned malformed response")) let value = jsonc_parser::parse_to_serde_value(&body, &Default::default())
.expect("Failed to parse JSON")
.expect("No JSON value");
Ok(serde_json::from_value(value).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 url = format!("http://localhost:{}/api/read/{}", self.port, id);
let body = reqwest::blocking::get(url)?.text()?; let body = reqwest::blocking::get(url)?.text()?;
Ok(serde_json::from_str(&body).expect("Server returned malformed response")) let value = jsonc_parser::parse_to_serde_value(&body, &Default::default())
.expect("Failed to parse JSON")
.expect("No JSON value");
Ok(serde_json::from_value(value).expect("Server returned malformed response"))
} }
pub fn get_api_subscribe( pub fn get_api_subscribe(
@@ -174,6 +181,18 @@ impl TestServeSession {
reqwest::blocking::get(url)?.json() 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 /// Probably-okay way to generate random enough port numbers for running the
@@ -187,3 +206,27 @@ fn get_port_number() -> usize {
NEXT_PORT_NUMBER.fetch_add(1, Ordering::SeqCst) 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 std::fs;
use insta::{assert_yaml_snapshot, with_settings}; use insta::{assert_snapshot, assert_yaml_snapshot, with_settings};
use tempfile::tempdir; 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] #[test]
fn empty() { 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);
});
}