mirror of
https://github.com/rojo-rbx/rojo.git
synced 2026-04-20 20:55:50 +00:00
Compare commits
118 Commits
v0.6.0-alp
...
v6.0.0-rc.
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
23b8308282 | ||
|
|
a1f7cdc2b6 | ||
|
|
dcb30351c5 | ||
|
|
1c0dc60071 | ||
|
|
0401c3ac0e | ||
|
|
3b800d1cd7 | ||
|
|
35efb464e5 | ||
|
|
19a955a327 | ||
|
|
836b18e68a | ||
|
|
046dc0d598 | ||
|
|
039d92ce78 | ||
|
|
2136da15d6 | ||
|
|
e5041d80ef | ||
|
|
f66860bdfe | ||
|
|
50f0a2bd2e | ||
|
|
7cd9bd383e | ||
|
|
45a20a1633 | ||
|
|
ec5b3f80ef | ||
|
|
3b257ea87a | ||
|
|
6b82cead9c | ||
|
|
79ae4c52cd | ||
|
|
a4616cda7d | ||
|
|
95648361be | ||
|
|
0c41e9c10b | ||
|
|
61c7ef3cb0 | ||
|
|
65898125d0 | ||
|
|
da05078ff3 | ||
|
|
badb5c3636 | ||
|
|
9453588ab1 | ||
|
|
4cbb3874a4 | ||
|
|
940aff7ef4 | ||
|
|
a3edb93273 | ||
|
|
782b054b1a | ||
|
|
fc27b2911e | ||
|
|
486b067567 | ||
|
|
bdd1afea57 | ||
|
|
5ccd02939b | ||
|
|
ca5b8ab309 | ||
|
|
9481fdd38d | ||
|
|
56bf6d282b | ||
|
|
5364c9c1bc | ||
|
|
a4d4beeb97 | ||
|
|
30a01381be | ||
|
|
def99a9e4d | ||
|
|
1214fc8b0d | ||
|
|
5a5b1268d3 | ||
|
|
6a1fffd1ce | ||
|
|
571ef3060a | ||
|
|
3cf82e112f | ||
|
|
9b459c20d6 | ||
|
|
5c85cd27c3 | ||
|
|
4bf73c7a8a | ||
|
|
62e51b7535 | ||
|
|
729a7f0053 | ||
|
|
03c297190d | ||
|
|
9c790eddd7 | ||
|
|
8ebe7e332b | ||
|
|
f43777e37e | ||
|
|
691a8fcdeb | ||
|
|
69c0e8d70e | ||
|
|
330c92c9a8 | ||
|
|
cf0ff60d31 | ||
|
|
9e9cf5dd1f | ||
|
|
5768d8e4a4 | ||
|
|
3b433e53be | ||
|
|
28ddf40344 | ||
|
|
c1286db9c1 | ||
|
|
f13940262e | ||
|
|
9f0a6101b8 | ||
|
|
0b0fe01a7c | ||
|
|
85e098d5c8 | ||
|
|
e8d1faf4e2 | ||
|
|
2a46df1110 | ||
|
|
1601e6d26e | ||
|
|
0e4f6dea2b | ||
|
|
a2356773dc | ||
|
|
4a4da4737d | ||
|
|
2cefd1bf2e | ||
|
|
c5ce15fe34 | ||
|
|
76dea568c9 | ||
|
|
8e81140eff | ||
|
|
d58e1f0792 | ||
|
|
830c242751 | ||
|
|
91d45afd0f | ||
|
|
102c77b23e | ||
|
|
aa4039a2e7 | ||
|
|
c065ded440 | ||
|
|
f69096dadb | ||
|
|
363f95ba14 | ||
|
|
dcc15e8911 | ||
|
|
bd13047b58 | ||
|
|
1a83789c01 | ||
|
|
1cbe272e19 | ||
|
|
6de74b41b3 | ||
|
|
b0fc9ee507 | ||
|
|
a95ffe1d31 | ||
|
|
4119a510f5 | ||
|
|
cfa7f03815 | ||
|
|
9b4c89820d | ||
|
|
fe874720aa | ||
|
|
f7c0f33eb5 | ||
|
|
c1bf9d9dfc | ||
|
|
255bf439d3 | ||
|
|
2a31937b81 | ||
|
|
eb8964e1d1 | ||
|
|
fe0ca280a1 | ||
|
|
e8e3b7b985 | ||
|
|
c437507442 | ||
|
|
ca151b434e | ||
|
|
10ba74c21e | ||
|
|
6191b6371d | ||
|
|
5be4175ac3 | ||
|
|
f61f3671a6 | ||
|
|
477e0ada32 | ||
|
|
a884f693ae | ||
|
|
3107b1b21b | ||
|
|
04529de7b3 | ||
|
|
199a39208c |
21
.github/workflows/ci.yml
vendored
21
.github/workflows/ci.yml
vendored
@@ -1,6 +1,13 @@
|
||||
name: CI
|
||||
|
||||
on: [push]
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
pull_request:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -9,10 +16,12 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
rust_version: [stable, "1.40.0"]
|
||||
rust_version: [stable, "1.43.1"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
run: rustup default ${{ matrix.rust_version }}
|
||||
@@ -27,10 +36,4 @@ jobs:
|
||||
run: |
|
||||
cargo fmt -- --check
|
||||
cargo clippy
|
||||
if: matrix.rust_version == 'stable'
|
||||
|
||||
- name: Build (All Features)
|
||||
run: cargo build --locked --verbose --all-features
|
||||
|
||||
- name: Run tests (All Features)
|
||||
run: cargo test --locked --verbose --all-features
|
||||
if: matrix.rust_version == 'stable'
|
||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -10,6 +10,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Build release binary
|
||||
run: cargo build --verbose --locked --release
|
||||
@@ -25,6 +27,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Install Rust
|
||||
run: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
|
||||
@@ -47,6 +51,8 @@ jobs:
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Build
|
||||
run: cargo build --locked --verbose --release
|
||||
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -10,9 +10,15 @@
|
||||
/*.rbxl
|
||||
/*.rbxlx
|
||||
|
||||
# Test places for the Roblox Studio Plugin
|
||||
/plugin/*.rbxlx
|
||||
|
||||
# Roblox Studio holds 'lock' files on places
|
||||
*.rbxl.lock
|
||||
*.rbxlx.lock
|
||||
|
||||
# Snapshot files from the 'insta' Rust crate
|
||||
**/*.snap.new
|
||||
**/*.snap.new
|
||||
|
||||
# Selene generates a roblox.toml file that should not be checked in.
|
||||
/roblox.toml
|
||||
5
.gitmodules
vendored
5
.gitmodules
vendored
@@ -9,7 +9,4 @@
|
||||
url = https://github.com/LPGhatguy/roblox-lua-promise.git
|
||||
[submodule "plugin/modules/t"]
|
||||
path = plugin/modules/t
|
||||
url = https://github.com/osyrisrblx/t.git
|
||||
[submodule "plugin/modules/rbx-dom"]
|
||||
path = plugin/modules/rbx-dom
|
||||
url = http://github.com/rojo-rbx/rbx-dom
|
||||
url = https://github.com/osyrisrblx/t.git
|
||||
@@ -33,7 +33,7 @@ stds.plugin = {
|
||||
stds.testez = {
|
||||
read_globals = {
|
||||
"describe",
|
||||
"it", "itFOCUS", "itSKIP",
|
||||
"it", "itFOCUS", "itSKIP", "itFIXME",
|
||||
"FOCUS", "SKIP", "HACK_NO_XPCALL",
|
||||
"expect",
|
||||
}
|
||||
|
||||
47
CHANGELOG.md
47
CHANGELOG.md
@@ -1,6 +1,47 @@
|
||||
# Rojo Changelog
|
||||
|
||||
## Unreleased Changes for 0.6.x
|
||||
## Unreleased Changes
|
||||
|
||||
## [6.0.0 Release Candidate 3](https://github.com/rojo-rbx/rojo/releases/tag/v6.0.0-rc.3) (November 19, 2020)
|
||||
* Fixed the Rojo plugin attempted to write the non-scriptable properties `Instance.SourceAssetId` and `HttpServer.HttpEnabled`.
|
||||
* Fixed the Rojo plugin's handling of null referents.
|
||||
|
||||
## [6.0.0 Release Candidate 2](https://github.com/rojo-rbx/rojo/releases/tag/v6.0.0-rc.2) (November 19, 2020)
|
||||
* Fixed crash when malformed CSV files are put into a project. ([#310](https://github.com/rojo-rbx/rojo/issues/310))
|
||||
* Fixed incorrect string escaping when producing Lua code from JSON files. ([#314](https://github.com/rojo-rbx/rojo/issues/314))
|
||||
* Fixed performance issues introduced in Rojo 6.0.0-rc.1. ([#317](https://github.com/rojo-rbx/rojo/issues/317))
|
||||
* Fixed `rojo plugin install` subcommand failing for everyone except Rojo developers. ([#320](https://github.com/rojo-rbx/rojo/issues/320))
|
||||
* Updated default place template to take advantage of [#210](https://github.com/rojo-rbx/rojo/pull/210).
|
||||
* Enabled glob ignore patterns by default and removed the `unstable_glob_ignore` feature.
|
||||
* `globIgnorePaths` can be set on a project to a list of globs to ignore.
|
||||
* The Rojo plugin now completes as much as it can from a patch without disconnecting. Warnings are shown in the console.
|
||||
* Fixed 6.0.0-rc.1 regression causing instances that changed ClassName to instead... not change ClassName.
|
||||
|
||||
## [6.0.0 Release Candidate 1](https://github.com/rojo-rbx/rojo/releases/tag/v6.0.0-rc.1) (March 29, 2020)
|
||||
This release jumped from 0.6.0 to 6.0.0. Rojo has been in use in production for many users for quite a long times, and so 6.0 is a more accurate reflection of Rojo's version than a pre-1.0 version.
|
||||
|
||||
* Added basic settings panel to plugin, with two settings:
|
||||
* "Open Scripts Externally": When enabled, opening a script in Studio will instead open it in your default text editor.
|
||||
* "Two-Way Sync": When enabled, Rojo will attempt to save changes to your place back to the filesystem. **Very early feature, very broken, beware!**
|
||||
* Added `--color` option to force-enable or force-disable color in Rojo's output.
|
||||
* Added support for turning `.json` files into `ModuleScript` instances ([#308](https://github.com/rojo-rbx/rojo/pull/308))
|
||||
* Added `rojo plugin install` and `rojo plugin uninstall` to allow Rojo to manage its Roblox Studio plugin. ([#304](https://github.com/rojo-rbx/rojo/pull/304))
|
||||
* Class names no longer need to be specified for Roblox services in Rojo projects. ([#210](https://github.com/rojo-rbx/rojo/pull/210))
|
||||
* The server half of **experimental** two-way sync is now enabled by default.
|
||||
* Increased default logging verbosity in commands like `rojo build`.
|
||||
* Rojo now requires a project file again, just like 0.5.4.
|
||||
|
||||
## [0.6.0 Alpha 3](https://github.com/rojo-rbx/rojo/releases/tag/v0.6.0-alpha.3) (March 13, 2020)
|
||||
* Added `--watch` argument to `rojo build`. ([#284](https://github.com/rojo-rbx/rojo/pull/284))
|
||||
* Added dark theme support to plugin. ([#241](https://github.com/rojo-rbx/rojo/issues/241))
|
||||
* Added a revamped `rojo init` command, which will now create more complete projects.
|
||||
* Added the `rojo doc` command, which opens Rojo's documentation in your browser.
|
||||
* Fixed many crashes from malformed projects and filesystem edge cases in `rojo serve`.
|
||||
* Simplified filesystem access code dramatically.
|
||||
* Improved error reporting and logging across the board.
|
||||
* Log messages have a less noisy prefix.
|
||||
* Any thread panicking now causes Rojo to abort instead of existing as a zombie.
|
||||
* Errors now have a list of causes, helping make many errors more clear.
|
||||
|
||||
## [0.6.0 Alpha 2](https://github.com/rojo-rbx/rojo/releases/tag/v0.6.0-alpha.2) (March 6, 2020)
|
||||
* Fixed `rojo upload` command always uploading models.
|
||||
@@ -96,7 +137,7 @@ This is a general maintenance release for the Rojo 0.5.x release series.
|
||||
|
||||
## [0.5.0 Alpha 9](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.0-alpha.9) (April 4, 2019)
|
||||
* Changed `rojo build` to use buffered I/O, which can make it up to 2x faster in some cases.
|
||||
* Building [*Road Not Taken*](https://github.com/rojo-rbx/roads) to an `rbxlx` file dropped from 150ms to 70ms on my machine
|
||||
* Building [*Road Not Taken*](https://github.com/LPGhatguy/roads) to an `rbxlx` file dropped from 150ms to 70ms on my machine
|
||||
* Fixed `LocalizationTable` instances being made from `csv` files incorrectly interpreting empty rows and columns. ([#149](https://github.com/rojo-rbx/rojo/pull/149))
|
||||
* Fixed CSV files with entries that parse as numbers causing Rojo to panic. ([#152](https://github.com/rojo-rbx/rojo/pull/152))
|
||||
* Improved error messages when malformed CSV files are found in a Rojo project.
|
||||
@@ -293,4 +334,4 @@ This is a general maintenance release for the Rojo 0.5.x release series.
|
||||
* More robust syncing with a new reconciler
|
||||
|
||||
## [0.1.0](https://github.com/rojo-rbx/rojo/releases/tag/v0.1.0) (November 29, 2017)
|
||||
* Initial release, functionally very similar to [rbxfs](https://github.com/rojo-rbx/rbxfs)
|
||||
* Initial release, functionally very similar to [rbxfs](https://github.com/LPGhatguy/rbxfs)
|
||||
@@ -1,5 +1,5 @@
|
||||
# Contributing to the Rojo Project
|
||||
Rojo is a big project and can always use more help! This guide covers all repositories underneath the [rojo-rbx organization on GitHub](https://github.com/rojo-rbx).
|
||||
Rojo is a big project and can always use more help!
|
||||
|
||||
Some of the repositories covered are:
|
||||
|
||||
@@ -15,8 +15,7 @@ You'll want these tools to work on Rojo:
|
||||
|
||||
* Latest stable Rust compiler
|
||||
* Latest stable [Rojo](https://github.com/rojo-rbx/rojo)
|
||||
* Latest stable [Remodel](https://github.com/rojo-rbx/remodel)
|
||||
* Latest stable [run-in-roblox](https://github.com/rojo-rbx/run-in-roblox)
|
||||
* [Foreman](https://github.com/Roblox/foreman)
|
||||
|
||||
## Documentation
|
||||
Documentation impacts way more people than the individual lines of code we write.
|
||||
@@ -33,15 +32,13 @@ Please file issues and we'll try to help figure out what the best way forward is
|
||||
## Pushing a Rojo Release
|
||||
The Rojo release process is pretty manual right now. If you need to do it, here's how:
|
||||
|
||||
1. Bump server version in [`server/Cargo.toml`](server/Cargo.toml)
|
||||
1. Bump server version in [`Cargo.toml`](Cargo.toml)
|
||||
2. Bump plugin version in [`plugin/src/Config.lua`](plugin/src/Config.lua)
|
||||
3. Run `cargo test` to update `Cargo.lock` and double-check tests
|
||||
4. Update [`CHANGELOG.md`](CHANGELOG.md)
|
||||
5. Commit!
|
||||
* `git add . && git commit -m "Release vX.Y.Z"`
|
||||
6. Tag the commit with the version from `Cargo.toml` prepended with a v, like `v0.4.13`
|
||||
7. Build Windows release build of CLI
|
||||
* `cargo build --release`
|
||||
7. Publish the CLI
|
||||
* `cargo publish`
|
||||
8. Build and upload the plugin
|
||||
@@ -52,4 +49,5 @@ The Rojo release process is pretty manual right now. If you need to do it, here'
|
||||
10. Copy GitHub release content from previous release
|
||||
* Update the leading text with a summary about the release
|
||||
* Paste the changelog notes (as-is!) from [`CHANGELOG.md`](CHANGELOG.md)
|
||||
* Write a small summary of each major feature
|
||||
* Write a small summary of each major feature
|
||||
* Attach release artifacts from GitHub Actions for each platform
|
||||
2571
Cargo.lock
generated
2571
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
41
Cargo.toml
41
Cargo.toml
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "rojo"
|
||||
version = "0.6.0-alpha.2"
|
||||
version = "6.0.0-rc.3"
|
||||
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
|
||||
description = "Enables professional-grade development tools for Roblox developers"
|
||||
license = "MPL-2.0"
|
||||
@@ -11,35 +11,32 @@ readme = "README.md"
|
||||
edition = "2018"
|
||||
|
||||
exclude = [
|
||||
"/plugin/**",
|
||||
"/test-projects/**",
|
||||
]
|
||||
|
||||
[profile.dev]
|
||||
panic = "abort"
|
||||
|
||||
[profile.release]
|
||||
panic = "abort"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
# Turn on support for specifying glob ignore path rules in the project format.
|
||||
unstable_glob_ignore_paths = []
|
||||
|
||||
# Turn on the server half of Rojo's unstable two-way sync feature.
|
||||
unstable_two_way_sync = []
|
||||
|
||||
# Enable this feature to live-reload assets from the web UI.
|
||||
dev_live_assets = []
|
||||
|
||||
[workspace]
|
||||
members = [
|
||||
"rojo-test",
|
||||
"rojo-insta-ext",
|
||||
"clibrojo",
|
||||
"vfs",
|
||||
"memofs",
|
||||
]
|
||||
|
||||
default-members = [
|
||||
".",
|
||||
"rojo-test",
|
||||
"rojo-insta-ext",
|
||||
"vfs",
|
||||
"memofs",
|
||||
]
|
||||
|
||||
[lib]
|
||||
@@ -55,9 +52,15 @@ name = "build"
|
||||
harness = false
|
||||
|
||||
[dependencies]
|
||||
memofs = { version = "0.1.2", path = "memofs" }
|
||||
|
||||
anyhow = "1.0.27"
|
||||
backtrace = "0.3"
|
||||
bincode = "1.2.1"
|
||||
crossbeam-channel = "0.4.0"
|
||||
csv = "1.1.1"
|
||||
env_logger = "0.7.1"
|
||||
fs-err = "2.2.0"
|
||||
futures = "0.1.29"
|
||||
globset = "0.4.4"
|
||||
humantime = "1.3.0"
|
||||
@@ -67,6 +70,7 @@ lazy_static = "1.4.0"
|
||||
log = "0.4.8"
|
||||
maplit = "1.0.1"
|
||||
notify = "4.0.14"
|
||||
opener = "0.4.1"
|
||||
rbx_binary = "0.5.0"
|
||||
rbx_dom_weak = "1.10.1"
|
||||
rbx_reflection = "3.3.408"
|
||||
@@ -75,16 +79,26 @@ regex = "1.3.1"
|
||||
reqwest = "0.9.20"
|
||||
ritz = "0.1.0"
|
||||
rlua = "0.17.0"
|
||||
roblox_install = "0.2.2"
|
||||
serde = { version = "1.0", features = ["derive", "rc"] }
|
||||
serde_json = "1.0"
|
||||
snafu = "0.6.0"
|
||||
structopt = "0.3.5"
|
||||
termcolor = "1.0.5"
|
||||
thiserror = "1.0.11"
|
||||
tokio = "0.1.22"
|
||||
uuid = { version = "0.8.1", features = ["v4", "serde"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
winreg = "0.6.2"
|
||||
|
||||
[build-dependencies]
|
||||
memofs = { version = "0.1.3", path = "memofs" }
|
||||
|
||||
anyhow = "1.0.27"
|
||||
bincode = "1.2.1"
|
||||
fs-err = "2.3.0"
|
||||
maplit = "1.0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
rojo-insta-ext = { path = "rojo-insta-ext" }
|
||||
|
||||
@@ -95,5 +109,4 @@ paste = "0.1"
|
||||
pretty_assertions = "0.6.1"
|
||||
serde_yaml = "0.8.9"
|
||||
tempfile = "3.0"
|
||||
tokio = "0.1.22"
|
||||
walkdir = "2.1"
|
||||
|
||||
11
README.md
11
README.md
@@ -13,8 +13,8 @@
|
||||
<a href="https://crates.io/crates/rojo">
|
||||
<img src="https://img.shields.io/crates/v/rojo.svg?label=latest%20release" alt="Latest server version" />
|
||||
</a>
|
||||
<a href="https://rojo.space/docs/0.5.x">
|
||||
<img src="https://img.shields.io/badge/docs-0.5.x-brightgreen.svg" alt="Rojo 0.5.x Documentation" />
|
||||
<a href="https://rojo.space/docs">
|
||||
<img src="https://img.shields.io/badge/docs-website-brightgreen.svg" alt="Rojo Documentation" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -34,11 +34,10 @@ Rojo enables:
|
||||
* Streaming `rbxmx` and `rbxm` models into your game in real time
|
||||
* Packaging and deploying your project to Roblox.com from the command line
|
||||
|
||||
Soon, Rojo will be able to:
|
||||
In the future, Rojo will be able to:
|
||||
|
||||
* Automatically convert your existing game to work with Rojo
|
||||
* Sync instances from Roblox Studio to the filesystem
|
||||
* Automatically manage your assets on Roblox.com, like images and sounds
|
||||
* Automatically convert your existing game to work with Rojo
|
||||
* Import custom instances like MoonScript code
|
||||
|
||||
## [Documentation](https://rojo.space/docs)
|
||||
@@ -49,7 +48,7 @@ Check out our [contribution guide](CONTRIBUTING.md) for detailed instructions fo
|
||||
|
||||
Pull requests are welcome!
|
||||
|
||||
Rojo supports Rust 1.40.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.43.1 and newer. The minimum supported version of Rust is based on the latest versions of the dependencies that Rojo has.
|
||||
|
||||
## License
|
||||
Rojo is available under the terms of the Mozilla Public License, Version 2.0. See [LICENSE.txt](LICENSE.txt) for details.
|
||||
11
assets/default-model-project/README.md
Normal file
11
assets/default-model-project/README.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# {project_name}
|
||||
Generated by [Rojo](https://github.com/rojo-rbx/rojo) {rojo_version}.
|
||||
|
||||
## Getting Started
|
||||
To build this library or plugin, use:
|
||||
|
||||
```bash
|
||||
rojo build -o "{project_name}.rbxmx"
|
||||
```
|
||||
|
||||
For more help, check out [the Rojo documentation](https://rojo.space/docs).
|
||||
6
assets/default-model-project/default.project.json
Normal file
6
assets/default-model-project/default.project.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "{project_name}",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
}
|
||||
3
assets/default-model-project/gitignore.txt
Normal file
3
assets/default-model-project/gitignore.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# Roblox Studio lock files
|
||||
/*.rbxlx.lock
|
||||
/*.rbxl.lock
|
||||
5
assets/default-model-project/src-init.lua
Normal file
5
assets/default-model-project/src-init.lua
Normal file
@@ -0,0 +1,5 @@
|
||||
return {
|
||||
hello = function()
|
||||
print("Hello world, from {project_name}!")
|
||||
end,
|
||||
}
|
||||
17
assets/default-place-project/README.md
Normal file
17
assets/default-place-project/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# {project_name}
|
||||
Generated by [Rojo](https://github.com/rojo-rbx/rojo) {rojo_version}.
|
||||
|
||||
## Getting Started
|
||||
To build the place from scratch, use:
|
||||
|
||||
```bash
|
||||
rojo build -o "{project_name}.rbxlx"
|
||||
```
|
||||
|
||||
Next, open `{project_name}.rbxlx` in Roblox Studio and start the Rojo server:
|
||||
|
||||
```bash
|
||||
rojo serve
|
||||
```
|
||||
|
||||
For more help, check out [the Rojo documentation](https://rojo.space/docs).
|
||||
@@ -1,41 +1,29 @@
|
||||
{
|
||||
"name": "[placeholder]",
|
||||
"name": "{project_name}",
|
||||
"tree": {
|
||||
"$className": "DataModel",
|
||||
"HttpService": {
|
||||
"$className": "HttpService",
|
||||
"$properties": {
|
||||
"HttpEnabled": true
|
||||
}
|
||||
},
|
||||
"Lighting": {
|
||||
"$className": "Lighting",
|
||||
"$properties": {
|
||||
"Ambient": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"Brightness": 2,
|
||||
"GlobalShadows": true,
|
||||
"Outlines": false,
|
||||
"Technology": "Voxel"
|
||||
}
|
||||
},
|
||||
|
||||
"ReplicatedStorage": {
|
||||
"$className": "ReplicatedStorage",
|
||||
"Source": {
|
||||
"$path": "src"
|
||||
"Common": {
|
||||
"$path": "src/shared"
|
||||
}
|
||||
},
|
||||
"SoundService": {
|
||||
"$className": "SoundService",
|
||||
"$properties": {
|
||||
"RespectFilteringEnabled": true
|
||||
|
||||
"ServerScriptService": {
|
||||
"Server": {
|
||||
"$path": "src/server"
|
||||
}
|
||||
},
|
||||
|
||||
"StarterPlayer": {
|
||||
"StarterPlayerScripts": {
|
||||
"Client": {
|
||||
"$path": "src/client"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"Workspace": {
|
||||
"$className": "Workspace",
|
||||
"$properties": {
|
||||
"FilteringEnabled": true
|
||||
},
|
||||
@@ -61,6 +49,24 @@
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Lighting": {
|
||||
"$properties": {
|
||||
"Ambient": [
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"Brightness": 2,
|
||||
"GlobalShadows": true,
|
||||
"Outlines": false,
|
||||
"Technology": "Voxel"
|
||||
}
|
||||
},
|
||||
"SoundService": {
|
||||
"$properties": {
|
||||
"RespectFilteringEnabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
6
assets/default-place-project/gitignore.txt
Normal file
6
assets/default-place-project/gitignore.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
# Project place file
|
||||
/{project_name}.rbxlx
|
||||
|
||||
# Roblox Studio lock files
|
||||
/*.rbxlx.lock
|
||||
/*.rbxl.lock
|
||||
@@ -35,6 +35,7 @@ fn place_setup<P: AsRef<Path>>(input_path: P) -> (TempDir, BuildCommand) {
|
||||
|
||||
let options = BuildCommand {
|
||||
project: input,
|
||||
watch: false,
|
||||
output,
|
||||
};
|
||||
|
||||
|
||||
74
build.rs
Normal file
74
build.rs
Normal file
@@ -0,0 +1,74 @@
|
||||
use std::{
|
||||
env, io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use fs_err as fs;
|
||||
use fs_err::File;
|
||||
use maplit::hashmap;
|
||||
use memofs::VfsSnapshot;
|
||||
|
||||
fn snapshot_from_fs_path(path: &Path) -> io::Result<VfsSnapshot> {
|
||||
println!("cargo:rerun-if-changed={}", path.display());
|
||||
|
||||
if path.is_dir() {
|
||||
let mut children = Vec::new();
|
||||
|
||||
for entry in fs::read_dir(path)? {
|
||||
let entry = entry?;
|
||||
|
||||
let file_name = entry.file_name().to_str().unwrap().to_owned();
|
||||
|
||||
// We can skip any TestEZ test files since they aren't necessary for
|
||||
// the plugin to run.
|
||||
if file_name.ends_with(".spec.lua") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let child_snapshot = snapshot_from_fs_path(&entry.path())?;
|
||||
children.push((file_name, child_snapshot));
|
||||
}
|
||||
|
||||
Ok(VfsSnapshot::dir(children))
|
||||
} else {
|
||||
let content = fs::read_to_string(path)?;
|
||||
|
||||
Ok(VfsSnapshot::file(content))
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<(), anyhow::Error> {
|
||||
let out_dir = env::var_os("OUT_DIR").unwrap();
|
||||
|
||||
let root_dir = env::var_os("CARGO_MANIFEST_DIR").unwrap();
|
||||
let plugin_root = PathBuf::from(root_dir).join("plugin");
|
||||
|
||||
let plugin_modules = plugin_root.join("modules");
|
||||
|
||||
let snapshot = VfsSnapshot::dir(hashmap! {
|
||||
"default.project.json" => snapshot_from_fs_path(&plugin_root.join("default.project.json"))?,
|
||||
"fmt" => snapshot_from_fs_path(&plugin_root.join("fmt"))?,
|
||||
"http" => snapshot_from_fs_path(&plugin_root.join("http"))?,
|
||||
"log" => snapshot_from_fs_path(&plugin_root.join("log"))?,
|
||||
"rbx_dom_lua" => snapshot_from_fs_path(&plugin_root.join("rbx_dom_lua"))?,
|
||||
"src" => snapshot_from_fs_path(&plugin_root.join("src"))?,
|
||||
"modules" => VfsSnapshot::dir(hashmap! {
|
||||
"roact" => VfsSnapshot::dir(hashmap! {
|
||||
"src" => snapshot_from_fs_path(&plugin_modules.join("roact").join("src"))?
|
||||
}),
|
||||
"promise" => VfsSnapshot::dir(hashmap! {
|
||||
"lib" => snapshot_from_fs_path(&plugin_modules.join("promise").join("lib"))?
|
||||
}),
|
||||
"t" => VfsSnapshot::dir(hashmap! {
|
||||
"lib" => snapshot_from_fs_path(&plugin_modules.join("t").join("lib"))?
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
let out_path = Path::new(&out_dir).join("plugin.bincode");
|
||||
let out_file = File::create(&out_path)?;
|
||||
|
||||
bincode::serialize_into(out_file, &snapshot)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
30
design.gv
30
design.gv
@@ -1,30 +0,0 @@
|
||||
digraph Rojo {
|
||||
concentrate = true;
|
||||
node [fontname = "sans-serif"];
|
||||
|
||||
plugin [label="Roblox Studio Plugin"]
|
||||
session [label="Session"]
|
||||
rbx_tree [label="Instance Tree"]
|
||||
imfs [label="In-Memory Filesystem"]
|
||||
fs_impl [label="Filesystem Implementation\n(stubbed in tests)"]
|
||||
fs [label="Real Filesystem"]
|
||||
snapshot_subsystem [label="Snapshot Subsystem\n(reconciler)"]
|
||||
snapshot_generator [label="Snapshot Generator"]
|
||||
user_middleware [label="User Middleware\n(MoonScript, etc.)"]
|
||||
builtin_middleware [label="Built-in Middleware\n(.lua, .rbxm, etc.)"]
|
||||
api [label="Web API"]
|
||||
file_watcher [label="File Watcher"]
|
||||
|
||||
session -> imfs
|
||||
session -> rbx_tree
|
||||
session -> snapshot_subsystem
|
||||
session -> snapshot_generator
|
||||
session -> file_watcher [dir="both"]
|
||||
file_watcher -> imfs
|
||||
snapshot_generator -> user_middleware
|
||||
snapshot_generator -> builtin_middleware
|
||||
plugin -> api [style="dotted"; dir="both"; minlen=2]
|
||||
api -> session
|
||||
imfs -> fs_impl
|
||||
fs_impl -> fs
|
||||
}
|
||||
3
foreman.toml
Normal file
3
foreman.toml
Normal file
@@ -0,0 +1,3 @@
|
||||
[tools]
|
||||
rojo = { source = "rojo-rbx/rojo", version = "6.0.0-rc.1" }
|
||||
run-in-roblox = { source = "rojo-rbx/run-in-roblox", version = "0.3.0" }
|
||||
15
memofs/CHANGELOG.md
Normal file
15
memofs/CHANGELOG.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# memofs Changelog
|
||||
|
||||
## Unreleased Changes
|
||||
|
||||
## 0.1.3 (2020-11-19)
|
||||
* Added `set_watch_enabled` to `Vfs` and `VfsLock` to allow turning off file watching.
|
||||
|
||||
## 0.1.2 (2020-03-29)
|
||||
* `VfsSnapshot` now implements Serde's `Serialize` and `Deserialize` traits.
|
||||
|
||||
## 0.1.1 (2020-03-18)
|
||||
* Improved error messages using the [fs-err](https://crates.io/crates/fs-err) crate.
|
||||
|
||||
## 0.1.0 (2020-03-10)
|
||||
* Initial release
|
||||
@@ -1,14 +1,17 @@
|
||||
[package]
|
||||
name = "vfs"
|
||||
version = "0.1.0"
|
||||
name = "memofs"
|
||||
description = "Virtual filesystem with configurable backends."
|
||||
version = "0.1.3"
|
||||
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
|
||||
edition = "2018"
|
||||
readme = "README.md"
|
||||
license = "MIT"
|
||||
homepage = "https://github.com/rojo-rbx/rojo"
|
||||
homepage = "https://github.com/rojo-rbx/rojo/tree/master/memofs"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
notify = "4.0.15"
|
||||
crossbeam-channel = "0.4.0"
|
||||
fs-err = "2.3.0"
|
||||
notify = "4.0.15"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
22
memofs/README.md
Normal file
22
memofs/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# memofs
|
||||
[](https://crates.io/crates/memofs)
|
||||
|
||||
Implementation of a virtual filesystem with a configurable backend and file
|
||||
watching.
|
||||
|
||||
memofs is currently an unstable minimum viable library. Its primary consumer is
|
||||
[Rojo](https://github.com/rojo-rbx/rojo), a build system for Roblox.
|
||||
|
||||
### Current Features
|
||||
* API similar to `std::fs`
|
||||
* Configurable backends
|
||||
* `StdBackend`, which uses `std::fs` and the `notify` crate
|
||||
* `NoopBackend`, which always throws errors
|
||||
* `InMemoryFs`, a simple in-memory filesystem useful for testing
|
||||
|
||||
### Future Features
|
||||
* Hash-based hierarchical memoization keys (hence the name)
|
||||
* Configurable caching (write-through, write-around, write-back)
|
||||
|
||||
## License
|
||||
memofs is available under the terms of the MIT license. See [LICENSE.txt](LICENSE.txt) or <https://opensource.org/licenses/MIT> for more details.
|
||||
7
memofs/README.tpl
Normal file
7
memofs/README.tpl
Normal file
@@ -0,0 +1,7 @@
|
||||
# {{crate}}
|
||||
[](https://crates.io/crates/memofs)
|
||||
|
||||
{{readme}}
|
||||
|
||||
## License
|
||||
memofs is available under the terms of the MIT license. See [LICENSE.txt](LICENSE.txt) or <https://opensource.org/licenses/MIT> for more details.
|
||||
@@ -1,32 +1,77 @@
|
||||
use std::collections::{BTreeSet, HashMap, VecDeque};
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crossbeam_channel::{Receiver, Sender};
|
||||
|
||||
use crate::{DirEntry, Metadata, ReadDir, VfsBackend, VfsEvent, VfsSnapshot};
|
||||
|
||||
/// `VfsBackend` that reads from an in-memory filesystem, intended for setting
|
||||
/// up testing scenarios quickly.
|
||||
#[derive(Debug)]
|
||||
pub struct MemoryBackend {
|
||||
entries: HashMap<PathBuf, Entry>,
|
||||
orphans: BTreeSet<PathBuf>,
|
||||
/// In-memory filesystem that can be used as a VFS backend.
|
||||
///
|
||||
/// Internally reference counted to enable giving a copy to
|
||||
/// [`Vfs`](struct.Vfs.html) and keeping the original to mutate the filesystem's
|
||||
/// state with.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InMemoryFs {
|
||||
inner: Arc<Mutex<InMemoryFsInner>>,
|
||||
}
|
||||
|
||||
impl MemoryBackend {
|
||||
impl InMemoryFs {
|
||||
/// Create a new empty `InMemoryFs`.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: HashMap::new(),
|
||||
orphans: BTreeSet::new(),
|
||||
inner: Arc::new(Mutex::new(InMemoryFsInner::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load a [`VfsSnapshot`](enum.VfsSnapshot.html) into a subtree of the
|
||||
/// in-memory filesystem.
|
||||
///
|
||||
/// This function will return an error if the operations required to apply
|
||||
/// the snapshot result in errors, like trying to create a file inside a
|
||||
/// file.
|
||||
pub fn load_snapshot<P: Into<PathBuf>>(
|
||||
&mut self,
|
||||
path: P,
|
||||
snapshot: VfsSnapshot,
|
||||
) -> io::Result<()> {
|
||||
let path = path.into();
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
inner.load_snapshot(path.into(), snapshot)
|
||||
}
|
||||
|
||||
/// Raises a filesystem change event.
|
||||
///
|
||||
/// If this `InMemoryFs` is being used as the backend of a
|
||||
/// [`Vfs`](struct.Vfs.html), then any listeners be notified of this event.
|
||||
pub fn raise_event(&mut self, event: VfsEvent) {
|
||||
let inner = self.inner.lock().unwrap();
|
||||
inner.event_sender.send(event).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct InMemoryFsInner {
|
||||
entries: HashMap<PathBuf, Entry>,
|
||||
orphans: BTreeSet<PathBuf>,
|
||||
|
||||
event_receiver: Receiver<VfsEvent>,
|
||||
event_sender: Sender<VfsEvent>,
|
||||
}
|
||||
|
||||
impl InMemoryFsInner {
|
||||
fn new() -> Self {
|
||||
let (event_sender, event_receiver) = crossbeam_channel::unbounded();
|
||||
|
||||
Self {
|
||||
entries: HashMap::new(),
|
||||
orphans: BTreeSet::new(),
|
||||
event_receiver,
|
||||
event_sender,
|
||||
}
|
||||
}
|
||||
|
||||
fn load_snapshot(&mut self, path: PathBuf, snapshot: VfsSnapshot) -> io::Result<()> {
|
||||
if let Some(parent_path) = path.parent() {
|
||||
if let Some(parent_entry) = self.entries.get_mut(parent_path) {
|
||||
if let Entry::Dir { children } = parent_entry {
|
||||
@@ -84,9 +129,11 @@ enum Entry {
|
||||
Dir { children: BTreeSet<PathBuf> },
|
||||
}
|
||||
|
||||
impl VfsBackend for MemoryBackend {
|
||||
impl VfsBackend for InMemoryFs {
|
||||
fn read(&mut self, path: &Path) -> io::Result<Vec<u8>> {
|
||||
match self.entries.get(path) {
|
||||
let inner = self.inner.lock().unwrap();
|
||||
|
||||
match inner.entries.get(path) {
|
||||
Some(Entry::File { contents }) => Ok(contents.clone()),
|
||||
Some(Entry::Dir { .. }) => must_be_file(path),
|
||||
None => not_found(path),
|
||||
@@ -94,8 +141,10 @@ impl VfsBackend for MemoryBackend {
|
||||
}
|
||||
|
||||
fn write(&mut self, path: &Path, data: &[u8]) -> io::Result<()> {
|
||||
self.load_snapshot(
|
||||
path,
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
|
||||
inner.load_snapshot(
|
||||
path.to_path_buf(),
|
||||
VfsSnapshot::File {
|
||||
contents: data.to_owned(),
|
||||
},
|
||||
@@ -103,7 +152,9 @@ impl VfsBackend for MemoryBackend {
|
||||
}
|
||||
|
||||
fn read_dir(&mut self, path: &Path) -> io::Result<ReadDir> {
|
||||
match self.entries.get(path) {
|
||||
let inner = self.inner.lock().unwrap();
|
||||
|
||||
match inner.entries.get(path) {
|
||||
Some(Entry::Dir { children }) => {
|
||||
let iter = children
|
||||
.clone()
|
||||
@@ -120,9 +171,11 @@ impl VfsBackend for MemoryBackend {
|
||||
}
|
||||
|
||||
fn remove_file(&mut self, path: &Path) -> io::Result<()> {
|
||||
match self.entries.get(path) {
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
|
||||
match inner.entries.get(path) {
|
||||
Some(Entry::File { .. }) => {
|
||||
self.remove(path.to_owned());
|
||||
inner.remove(path.to_owned());
|
||||
Ok(())
|
||||
}
|
||||
Some(Entry::Dir { .. }) => must_be_file(path),
|
||||
@@ -131,9 +184,11 @@ impl VfsBackend for MemoryBackend {
|
||||
}
|
||||
|
||||
fn remove_dir_all(&mut self, path: &Path) -> io::Result<()> {
|
||||
match self.entries.get(path) {
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
|
||||
match inner.entries.get(path) {
|
||||
Some(Entry::Dir { .. }) => {
|
||||
self.remove(path.to_owned());
|
||||
inner.remove(path.to_owned());
|
||||
Ok(())
|
||||
}
|
||||
Some(Entry::File { .. }) => must_be_dir(path),
|
||||
@@ -142,7 +197,9 @@ impl VfsBackend for MemoryBackend {
|
||||
}
|
||||
|
||||
fn metadata(&mut self, path: &Path) -> io::Result<Metadata> {
|
||||
match self.entries.get(path) {
|
||||
let inner = self.inner.lock().unwrap();
|
||||
|
||||
match inner.entries.get(path) {
|
||||
Some(Entry::File { .. }) => Ok(Metadata { is_file: true }),
|
||||
Some(Entry::Dir { .. }) => Ok(Metadata { is_file: false }),
|
||||
None => not_found(path),
|
||||
@@ -150,7 +207,9 @@ impl VfsBackend for MemoryBackend {
|
||||
}
|
||||
|
||||
fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
|
||||
crossbeam_channel::never()
|
||||
let inner = self.inner.lock().unwrap();
|
||||
|
||||
inner.event_receiver.clone()
|
||||
}
|
||||
|
||||
fn watch(&mut self, _path: &Path) -> io::Result<()> {
|
||||
@@ -1,4 +1,23 @@
|
||||
mod memory_backend;
|
||||
/*!
|
||||
Implementation of a virtual filesystem with a configurable backend and file
|
||||
watching.
|
||||
|
||||
memofs is currently an unstable minimum viable library. Its primary consumer is
|
||||
[Rojo](https://github.com/rojo-rbx/rojo), a build system for Roblox.
|
||||
|
||||
## Current Features
|
||||
* API similar to `std::fs`
|
||||
* Configurable backends
|
||||
* `StdBackend`, which uses `std::fs` and the `notify` crate
|
||||
* `NoopBackend`, which always throws errors
|
||||
* `InMemoryFs`, a simple in-memory filesystem useful for testing
|
||||
|
||||
## Future Features
|
||||
* Hash-based hierarchical memoization keys (hence the name)
|
||||
* Configurable caching (write-through, write-around, write-back)
|
||||
*/
|
||||
|
||||
mod in_memory_fs;
|
||||
mod noop_backend;
|
||||
mod snapshot;
|
||||
mod std_backend;
|
||||
@@ -7,7 +26,7 @@ use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Arc, Mutex, MutexGuard};
|
||||
|
||||
pub use memory_backend::MemoryBackend;
|
||||
pub use in_memory_fs::InMemoryFs;
|
||||
pub use noop_backend::NoopBackend;
|
||||
pub use snapshot::VfsSnapshot;
|
||||
pub use std_backend::StdBackend;
|
||||
@@ -18,9 +37,9 @@ mod sealed {
|
||||
/// Sealing trait for VfsBackend.
|
||||
pub trait Sealed {}
|
||||
|
||||
impl Sealed for MemoryBackend {}
|
||||
impl Sealed for NoopBackend {}
|
||||
impl Sealed for StdBackend {}
|
||||
impl Sealed for InMemoryFs {}
|
||||
}
|
||||
|
||||
/// Trait that transforms `io::Result<T>` into `io::Result<Option<T>>`.
|
||||
@@ -107,6 +126,8 @@ impl Metadata {
|
||||
}
|
||||
}
|
||||
|
||||
/// Represents an event that a filesystem can raise that might need to be
|
||||
/// handled.
|
||||
#[derive(Debug)]
|
||||
#[non_exhaustive]
|
||||
pub enum VfsEvent {
|
||||
@@ -119,13 +140,18 @@ pub enum VfsEvent {
|
||||
/// the public interfaces to this type.
|
||||
struct VfsInner {
|
||||
backend: Box<dyn VfsBackend>,
|
||||
watch_enabled: bool,
|
||||
}
|
||||
|
||||
impl VfsInner {
|
||||
fn read<P: AsRef<Path>>(&mut self, path: P) -> io::Result<Arc<Vec<u8>>> {
|
||||
let path = path.as_ref();
|
||||
let contents = self.backend.read(path)?;
|
||||
self.backend.watch(path)?;
|
||||
|
||||
if self.watch_enabled {
|
||||
self.backend.watch(path)?;
|
||||
}
|
||||
|
||||
Ok(Arc::new(contents))
|
||||
}
|
||||
|
||||
@@ -138,7 +164,11 @@ impl VfsInner {
|
||||
fn read_dir<P: AsRef<Path>>(&mut self, path: P) -> io::Result<ReadDir> {
|
||||
let path = path.as_ref();
|
||||
let dir = self.backend.read_dir(path)?;
|
||||
self.backend.watch(path)?;
|
||||
|
||||
if self.watch_enabled {
|
||||
self.backend.watch(path)?;
|
||||
}
|
||||
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
@@ -176,6 +206,10 @@ impl VfsInner {
|
||||
}
|
||||
|
||||
/// A virtual filesystem with a configurable backend.
|
||||
///
|
||||
/// All operations on the Vfs take a lock on an internal backend. For performing
|
||||
/// large batches of operations, it might be more performant to call `lock()`
|
||||
/// and use [`VfsLock`](struct.VfsLock.html) instead.
|
||||
pub struct Vfs {
|
||||
inner: Mutex<VfsInner>,
|
||||
}
|
||||
@@ -190,6 +224,7 @@ impl Vfs {
|
||||
pub fn new<B: VfsBackend>(backend: B) -> Self {
|
||||
let lock = VfsInner {
|
||||
backend: Box::new(backend),
|
||||
watch_enabled: true,
|
||||
};
|
||||
|
||||
Self {
|
||||
@@ -197,12 +232,23 @@ impl Vfs {
|
||||
}
|
||||
}
|
||||
|
||||
/// Manually lock the Vfs, useful for large batches of operations.
|
||||
pub fn lock(&self) -> VfsLock<'_> {
|
||||
VfsLock {
|
||||
inner: self.inner.lock().unwrap(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Turns automatic file watching on or off. Enabled by default.
|
||||
///
|
||||
/// Turning off file watching may be useful for single-use cases, especially
|
||||
/// on platforms like macOS where registering file watches has significant
|
||||
/// performance cost.
|
||||
pub fn set_watch_enabled(&self, enabled: bool) {
|
||||
let mut inner = self.inner.lock().unwrap();
|
||||
inner.watch_enabled = enabled;
|
||||
}
|
||||
|
||||
/// Read a file from the VFS, or the underlying backend if it isn't
|
||||
/// resident.
|
||||
///
|
||||
@@ -284,12 +330,23 @@ impl Vfs {
|
||||
}
|
||||
}
|
||||
|
||||
/// A locked handle to a `Vfs`, created by `Vfs::lock`.
|
||||
/// A locked handle to a [`Vfs`](struct.Vfs.html), created by `Vfs::lock`.
|
||||
///
|
||||
/// Implements roughly the same API as [`Vfs`](struct.Vfs.html).
|
||||
pub struct VfsLock<'a> {
|
||||
inner: MutexGuard<'a, VfsInner>,
|
||||
}
|
||||
|
||||
impl VfsLock<'_> {
|
||||
/// Turns automatic file watching on or off. Enabled by default.
|
||||
///
|
||||
/// Turning off file watching may be useful for single-use cases, especially
|
||||
/// on platforms like macOS where registering file watches has significant
|
||||
/// performance cost.
|
||||
pub fn set_watch_enabled(&mut self, enabled: bool) {
|
||||
self.inner.watch_enabled = enabled;
|
||||
}
|
||||
|
||||
/// Read a file from the VFS, or the underlying backend if it isn't
|
||||
/// resident.
|
||||
///
|
||||
@@ -1,5 +1,9 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// A slice of a tree of files. Can be loaded into an
|
||||
/// [`InMemoryFs`](struct.InMemoryFs.html).
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[non_exhaustive]
|
||||
pub enum VfsSnapshot {
|
||||
File {
|
||||
@@ -26,4 +30,16 @@ impl VfsSnapshot {
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn empty_file() -> Self {
|
||||
Self::File {
|
||||
contents: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn empty_dir() -> Self {
|
||||
Self::Dir {
|
||||
children: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::sync::mpsc;
|
||||
@@ -55,19 +54,22 @@ impl StdBackend {
|
||||
|
||||
impl VfsBackend for StdBackend {
|
||||
fn read(&mut self, path: &Path) -> io::Result<Vec<u8>> {
|
||||
fs::read(path)
|
||||
fs_err::read(path)
|
||||
}
|
||||
|
||||
fn write(&mut self, path: &Path, data: &[u8]) -> io::Result<()> {
|
||||
fs::write(path, data)
|
||||
fs_err::write(path, data)
|
||||
}
|
||||
|
||||
fn read_dir(&mut self, path: &Path) -> io::Result<ReadDir> {
|
||||
let inner = fs::read_dir(path)?.map(|entry| {
|
||||
Ok(DirEntry {
|
||||
path: entry?.path(),
|
||||
})
|
||||
});
|
||||
let entries: Result<Vec<_>, _> = fs_err::read_dir(path)?.collect();
|
||||
let mut entries = entries?;
|
||||
|
||||
entries.sort_by_cached_key(|entry| entry.file_name());
|
||||
|
||||
let inner = entries
|
||||
.into_iter()
|
||||
.map(|entry| Ok(DirEntry { path: entry.path() }));
|
||||
|
||||
Ok(ReadDir {
|
||||
inner: Box::new(inner),
|
||||
@@ -75,15 +77,15 @@ impl VfsBackend for StdBackend {
|
||||
}
|
||||
|
||||
fn remove_file(&mut self, path: &Path) -> io::Result<()> {
|
||||
fs::remove_file(path)
|
||||
fs_err::remove_file(path)
|
||||
}
|
||||
|
||||
fn remove_dir_all(&mut self, path: &Path) -> io::Result<()> {
|
||||
fs::remove_dir_all(path)
|
||||
fs_err::remove_dir_all(path)
|
||||
}
|
||||
|
||||
fn metadata(&mut self, path: &Path) -> io::Result<Metadata> {
|
||||
let inner = fs::metadata(path)?;
|
||||
let inner = fs_err::metadata(path)?;
|
||||
|
||||
Ok(Metadata {
|
||||
is_file: inner.is_file(),
|
||||
10
perf-test.sh
Normal file
10
perf-test.sh
Normal file
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
cargo build --release
|
||||
|
||||
echo "Known good:"
|
||||
time rojo build ../uiblox/test-place.project.json -o UIBlox.rbxlx
|
||||
|
||||
echo "Current:"
|
||||
time ./target/release/rojo build ../uiblox/test-place.project.json -o UIBlox.rbxlx
|
||||
@@ -14,6 +14,9 @@
|
||||
"Fmt": {
|
||||
"$path": "fmt"
|
||||
},
|
||||
"RbxDom": {
|
||||
"$path": "rbx_dom_lua"
|
||||
},
|
||||
"Roact": {
|
||||
"$path": "modules/roact/src"
|
||||
},
|
||||
@@ -22,9 +25,6 @@
|
||||
},
|
||||
"t": {
|
||||
"$path": "modules/t/lib"
|
||||
},
|
||||
"RbxDom": {
|
||||
"$path": "modules/rbx-dom/rbx_dom_lua/src"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -53,4 +53,8 @@ function Log.warn(template, ...)
|
||||
end
|
||||
end
|
||||
|
||||
function Log.error(template, ...)
|
||||
error(Fmt.fmt(template, ...))
|
||||
end
|
||||
|
||||
return Log
|
||||
Submodule plugin/modules/rbx-dom deleted from 5bca08fec3
Submodule plugin/modules/roact updated: b1db3f82a2...f7d2f1ce1d
44
plugin/rbx_dom_lua/.luacheckrc
Normal file
44
plugin/rbx_dom_lua/.luacheckrc
Normal file
@@ -0,0 +1,44 @@
|
||||
stds.roblox = {
|
||||
read_globals = {
|
||||
game = {
|
||||
other_fields = true,
|
||||
},
|
||||
|
||||
-- Roblox globals
|
||||
"script",
|
||||
|
||||
-- Extra functions
|
||||
"tick", "warn",
|
||||
"wait", "typeof",
|
||||
|
||||
-- Types
|
||||
"CFrame",
|
||||
"Color3",
|
||||
"Enum",
|
||||
"Instance",
|
||||
"NumberRange",
|
||||
"Rect",
|
||||
"UDim", "UDim2",
|
||||
"Vector2", "Vector3",
|
||||
"Vector2int16", "Vector3int16",
|
||||
}
|
||||
}
|
||||
|
||||
stds.testez = {
|
||||
read_globals = {
|
||||
"describe",
|
||||
"it", "itFOCUS", "itSKIP",
|
||||
"FOCUS", "SKIP", "HACK_NO_XPCALL",
|
||||
"expect",
|
||||
}
|
||||
}
|
||||
|
||||
ignore = {
|
||||
"212", -- unused arguments
|
||||
}
|
||||
|
||||
std = "lua51+roblox"
|
||||
|
||||
files["**/*.spec.lua"] = {
|
||||
std = "+testez",
|
||||
}
|
||||
2
plugin/rbx_dom_lua/README.md
Normal file
2
plugin/rbx_dom_lua/README.md
Normal file
@@ -0,0 +1,2 @@
|
||||
# rbx_dom_lua
|
||||
Roblox Lua implementation of rbx-dom mechanisms, intended to work with rbx_dom_weak and friends.
|
||||
6
plugin/rbx_dom_lua/default.project.json
Normal file
6
plugin/rbx_dom_lua/default.project.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "rbx_dom_lua",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
}
|
||||
242
plugin/rbx_dom_lua/src/EncodedValue.lua
Normal file
242
plugin/rbx_dom_lua/src/EncodedValue.lua
Normal file
@@ -0,0 +1,242 @@
|
||||
local base64 = require(script.Parent.base64)
|
||||
|
||||
local function identity(...)
|
||||
return ...
|
||||
end
|
||||
|
||||
local function unpackDecoder(f)
|
||||
return function(value)
|
||||
return f(unpack(value))
|
||||
end
|
||||
end
|
||||
|
||||
local function serializeFloat(value)
|
||||
-- TODO: Figure out a better way to serialize infinity and NaN, neither of
|
||||
-- which fit into JSON.
|
||||
if value == math.huge or value == -math.huge then
|
||||
return 999999999 * math.sign(value)
|
||||
end
|
||||
|
||||
return value
|
||||
end
|
||||
|
||||
local encoders
|
||||
encoders = {
|
||||
Bool = identity,
|
||||
Content = identity,
|
||||
Float32 = serializeFloat,
|
||||
Float64 = serializeFloat,
|
||||
Int32 = identity,
|
||||
Int64 = identity,
|
||||
String = identity,
|
||||
|
||||
BinaryString = base64.encode,
|
||||
SharedString = base64.encode,
|
||||
|
||||
BrickColor = function(value)
|
||||
return value.Number
|
||||
end,
|
||||
|
||||
CFrame = function(value)
|
||||
return {value:GetComponents()}
|
||||
end,
|
||||
Color3 = function(value)
|
||||
return {value.r, value.g, value.b}
|
||||
end,
|
||||
NumberRange = function(value)
|
||||
return {value.Min, value.Max}
|
||||
end,
|
||||
NumberSequence = function(value)
|
||||
local keypoints = {}
|
||||
|
||||
for index, keypoint in ipairs(value.Keypoints) do
|
||||
keypoints[index] = {
|
||||
Time = keypoint.Time,
|
||||
Value = keypoint.Value,
|
||||
Envelope = keypoint.Envelope,
|
||||
}
|
||||
end
|
||||
|
||||
return {
|
||||
Keypoints = keypoints,
|
||||
}
|
||||
end,
|
||||
ColorSequence = function(value)
|
||||
local keypoints = {}
|
||||
|
||||
for index, keypoint in ipairs(value.Keypoints) do
|
||||
keypoints[index] = {
|
||||
Time = keypoint.Time,
|
||||
Color = encoders.Color3(keypoint.Value),
|
||||
}
|
||||
end
|
||||
|
||||
return {
|
||||
Keypoints = keypoints,
|
||||
}
|
||||
end,
|
||||
Rect = function(value)
|
||||
return {
|
||||
Min = {value.Min.X, value.Min.Y},
|
||||
Max = {value.Max.X, value.Max.Y},
|
||||
}
|
||||
end,
|
||||
UDim = function(value)
|
||||
return {value.Scale, value.Offset}
|
||||
end,
|
||||
UDim2 = function(value)
|
||||
return {value.X.Scale, value.X.Offset, value.Y.Scale, value.Y.Offset}
|
||||
end,
|
||||
Vector2 = function(value)
|
||||
return {
|
||||
serializeFloat(value.X),
|
||||
serializeFloat(value.Y),
|
||||
}
|
||||
end,
|
||||
Vector2int16 = function(value)
|
||||
return {value.X, value.Y}
|
||||
end,
|
||||
Vector3 = function(value)
|
||||
return {
|
||||
serializeFloat(value.X),
|
||||
serializeFloat(value.Y),
|
||||
serializeFloat(value.Z),
|
||||
}
|
||||
end,
|
||||
Vector3int16 = function(value)
|
||||
return {value.X, value.Y, value.Z}
|
||||
end,
|
||||
|
||||
PhysicalProperties = function(value)
|
||||
if value == nil then
|
||||
return nil
|
||||
else
|
||||
return {
|
||||
Density = value.Density,
|
||||
Friction = value.Friction,
|
||||
Elasticity = value.Elasticity,
|
||||
FrictionWeight = value.FrictionWeight,
|
||||
ElasticityWeight = value.ElasticityWeight,
|
||||
}
|
||||
end
|
||||
end,
|
||||
|
||||
Ref = function(value)
|
||||
return nil
|
||||
end,
|
||||
}
|
||||
|
||||
local decoders = {
|
||||
Bool = identity,
|
||||
Content = identity,
|
||||
Enum = identity,
|
||||
Float32 = identity,
|
||||
Float64 = identity,
|
||||
Int32 = identity,
|
||||
Int64 = identity,
|
||||
String = identity,
|
||||
|
||||
BinaryString = base64.decode,
|
||||
SharedString = base64.decode,
|
||||
|
||||
BrickColor = BrickColor.new,
|
||||
|
||||
CFrame = unpackDecoder(CFrame.new),
|
||||
Color3 = unpackDecoder(Color3.new),
|
||||
Color3uint8 = unpackDecoder(Color3.fromRGB),
|
||||
NumberRange = unpackDecoder(NumberRange.new),
|
||||
UDim = unpackDecoder(UDim.new),
|
||||
UDim2 = unpackDecoder(UDim2.new),
|
||||
Vector2 = unpackDecoder(Vector2.new),
|
||||
Vector2int16 = unpackDecoder(Vector2int16.new),
|
||||
Vector3 = unpackDecoder(Vector3.new),
|
||||
Vector3int16 = unpackDecoder(Vector3int16.new),
|
||||
|
||||
Rect = function(value)
|
||||
return Rect.new(value.Min[1], value.Min[2], value.Max[1], value.Max[2])
|
||||
end,
|
||||
|
||||
NumberSequence = function(value)
|
||||
local keypoints = {}
|
||||
|
||||
for index, keypoint in ipairs(value.Keypoints) do
|
||||
keypoints[index] = NumberSequenceKeypoint.new(
|
||||
keypoint.Time,
|
||||
keypoint.Value,
|
||||
keypoint.Envelope
|
||||
)
|
||||
end
|
||||
|
||||
return NumberSequence.new(keypoints)
|
||||
end,
|
||||
|
||||
ColorSequence = function(value)
|
||||
local keypoints = {}
|
||||
|
||||
for index, keypoint in ipairs(value.Keypoints) do
|
||||
keypoints[index] = ColorSequenceKeypoint.new(
|
||||
keypoint.Time,
|
||||
Color3.new(unpack(keypoint.Color))
|
||||
)
|
||||
end
|
||||
|
||||
return ColorSequence.new(keypoints)
|
||||
end,
|
||||
|
||||
PhysicalProperties = function(properties)
|
||||
if properties == nil then
|
||||
return nil
|
||||
else
|
||||
return PhysicalProperties.new(
|
||||
properties.Density,
|
||||
properties.Friction,
|
||||
properties.Elasticity,
|
||||
properties.FrictionWeight,
|
||||
properties.ElasticityWeight
|
||||
)
|
||||
end
|
||||
end,
|
||||
|
||||
Ref = function()
|
||||
return nil
|
||||
end,
|
||||
}
|
||||
|
||||
local EncodedValue = {}
|
||||
|
||||
function EncodedValue.decode(encodedValue)
|
||||
local decoder = decoders[encodedValue.Type]
|
||||
if decoder ~= nil then
|
||||
return true, decoder(encodedValue.Value)
|
||||
end
|
||||
|
||||
return false, "Couldn't decode value " .. tostring(encodedValue.Type)
|
||||
end
|
||||
|
||||
function EncodedValue.encode(rbxValue, propertyType)
|
||||
assert(propertyType ~= nil, "Property type descriptor is required")
|
||||
|
||||
if propertyType.type == "Data" then
|
||||
local encoder = encoders[propertyType.name]
|
||||
|
||||
if encoder == nil then
|
||||
return false, ("Missing encoder for property type %q"):format(propertyType.name)
|
||||
end
|
||||
|
||||
if encoder ~= nil then
|
||||
return true, {
|
||||
Type = propertyType.name,
|
||||
Value = encoder(rbxValue),
|
||||
}
|
||||
end
|
||||
elseif propertyType.type == "Enum" then
|
||||
return true, {
|
||||
Type = "Enum",
|
||||
Value = rbxValue.Value,
|
||||
}
|
||||
end
|
||||
|
||||
return false, ("Unknown property descriptor type %q"):format(tostring(propertyType.type))
|
||||
end
|
||||
|
||||
return EncodedValue
|
||||
127
plugin/rbx_dom_lua/src/EncodedValue.spec.lua
Normal file
127
plugin/rbx_dom_lua/src/EncodedValue.spec.lua
Normal file
@@ -0,0 +1,127 @@
|
||||
return function()
|
||||
local RbxDom = require(script.Parent)
|
||||
local EncodedValue = require(script.Parent.EncodedValue)
|
||||
|
||||
it("should decode Rect values", function()
|
||||
local input = {
|
||||
Type = "Rect",
|
||||
Value = {
|
||||
Min = {1, 2},
|
||||
Max = {3, 4},
|
||||
},
|
||||
}
|
||||
|
||||
local output = Rect.new(1, 2, 3, 4)
|
||||
|
||||
local ok, decoded = EncodedValue.decode(input)
|
||||
|
||||
assert(ok, decoded)
|
||||
expect(decoded).to.equal(output)
|
||||
end)
|
||||
|
||||
it("should decode ColorSequence values", function()
|
||||
local input = {
|
||||
Type = "ColorSequence",
|
||||
Value = {
|
||||
Keypoints = {
|
||||
{
|
||||
Time = 0,
|
||||
Color = { 0.12, 0.34, 0.56 },
|
||||
},
|
||||
|
||||
{
|
||||
Time = 1,
|
||||
Color = { 0.13, 0.33, 0.37 },
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
local output = ColorSequence.new({
|
||||
ColorSequenceKeypoint.new(0, Color3.new(0.12, 0.34, 0.56)),
|
||||
ColorSequenceKeypoint.new(1, Color3.new(0.13, 0.33, 0.37)),
|
||||
})
|
||||
|
||||
local ok, decoded = EncodedValue.decode(input)
|
||||
assert(ok, decoded)
|
||||
expect(decoded).to.equal(output)
|
||||
end)
|
||||
|
||||
it("should decode NumberSequence values", function()
|
||||
local input = {
|
||||
Type = "NumberSequence",
|
||||
Value = {
|
||||
Keypoints = {
|
||||
{
|
||||
Time = 0,
|
||||
Value = 0.5,
|
||||
Envelope = 0,
|
||||
},
|
||||
|
||||
{
|
||||
Time = 1,
|
||||
Value = 0.5,
|
||||
Envelope = 0,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
local output = NumberSequence.new({
|
||||
NumberSequenceKeypoint.new(0, 0.5, 0),
|
||||
NumberSequenceKeypoint.new(1, 0.5, 0),
|
||||
})
|
||||
|
||||
local ok, decoded = EncodedValue.decode(input)
|
||||
assert(ok, decoded)
|
||||
expect(decoded).to.equal(output)
|
||||
end)
|
||||
|
||||
it("should decode PhysicalProperties values", function()
|
||||
local input = {
|
||||
Type = "PhysicalProperties",
|
||||
Value = {
|
||||
Density = 0.1,
|
||||
Friction = 0.2,
|
||||
Elasticity = 0.3,
|
||||
FrictionWeight = 0.4,
|
||||
ElasticityWeight = 0.5,
|
||||
},
|
||||
}
|
||||
|
||||
local output = PhysicalProperties.new(
|
||||
0.1,
|
||||
0.2,
|
||||
0.3,
|
||||
0.4,
|
||||
0.5
|
||||
)
|
||||
|
||||
local ok, decoded = EncodedValue.decode(input)
|
||||
assert(ok, decoded)
|
||||
expect(decoded).to.equal(output)
|
||||
end)
|
||||
|
||||
-- This part of rbx_dom_lua needs some work still.
|
||||
itSKIP("should encode Rect values", function()
|
||||
local input = Rect.new(10, 20, 30, 40)
|
||||
|
||||
local output = {
|
||||
Type = "Rect",
|
||||
Value = {
|
||||
Min = {10, 20},
|
||||
Max = {30, 40},
|
||||
},
|
||||
}
|
||||
|
||||
local descriptor = RbxDom.findCanonicalPropertyDescriptor("ImageLabel", "SliceCenter")
|
||||
local ok, encoded = EncodedValue.encode(input, descriptor)
|
||||
|
||||
assert(ok, encoded)
|
||||
expect(encoded.Type).to.equal(output.Type)
|
||||
expect(encoded.Value.Min[1]).to.equal(output.Value.Min[1])
|
||||
expect(encoded.Value.Min[2]).to.equal(output.Value.Min[2])
|
||||
expect(encoded.Value.Max[1]).to.equal(output.Value.Max[1])
|
||||
expect(encoded.Value.Max[2]).to.equal(output.Value.Max[2])
|
||||
end)
|
||||
end
|
||||
28
plugin/rbx_dom_lua/src/Error.lua
Normal file
28
plugin/rbx_dom_lua/src/Error.lua
Normal file
@@ -0,0 +1,28 @@
|
||||
local Error = {}
|
||||
Error.__index = Error
|
||||
|
||||
Error.Kind = {
|
||||
UnknownProperty = "UnknownProperty",
|
||||
PropertyNotReadable = "PropertyNotReadable",
|
||||
PropertyNotWritable = "PropertyNotWritable",
|
||||
Roblox = "Roblox",
|
||||
}
|
||||
|
||||
setmetatable(Error.Kind, {
|
||||
__index = function(_, key)
|
||||
error(("%q is not a valid member of Error.Kind"):format(tostring(key)), 2)
|
||||
end,
|
||||
})
|
||||
|
||||
function Error.new(kind, extra)
|
||||
return setmetatable({
|
||||
kind = kind,
|
||||
extra = extra,
|
||||
}, Error)
|
||||
end
|
||||
|
||||
function Error:__tostring()
|
||||
return ("Error(%s: %s)"):format(self.kind, tostring(self.extra))
|
||||
end
|
||||
|
||||
return Error
|
||||
80
plugin/rbx_dom_lua/src/PropertyDescriptor.lua
Normal file
80
plugin/rbx_dom_lua/src/PropertyDescriptor.lua
Normal file
@@ -0,0 +1,80 @@
|
||||
local Error = require(script.Parent.Error)
|
||||
local customProperties = require(script.Parent.customProperties)
|
||||
|
||||
-- A wrapper around a property descriptor from the reflection database with some
|
||||
-- extra convenience methods.
|
||||
--
|
||||
-- The aim of this API is to facilitate looking up a property once, then reading
|
||||
-- from it or writing to it multiple times. It's also useful when a consumer
|
||||
-- wants to check additional constraints on the property before trying to use
|
||||
-- it, like scriptability.
|
||||
local PropertyDescriptor = {}
|
||||
PropertyDescriptor.__index = PropertyDescriptor
|
||||
|
||||
local function get(container, key)
|
||||
return container[key]
|
||||
end
|
||||
|
||||
local function set(container, key, value)
|
||||
container[key] = value
|
||||
end
|
||||
|
||||
function PropertyDescriptor.fromRaw(data, className, propertyName)
|
||||
return setmetatable({
|
||||
scriptability = data.scriptability,
|
||||
className = className,
|
||||
name = propertyName,
|
||||
}, PropertyDescriptor)
|
||||
end
|
||||
|
||||
function PropertyDescriptor:read(instance)
|
||||
if self.scriptability == "ReadWrite" or self.scriptability == "Read" then
|
||||
local success, value = xpcall(get, debug.traceback, instance, self.name)
|
||||
|
||||
if success then
|
||||
return success, value
|
||||
else
|
||||
return false, Error.new(Error.Kind.Roblox, value)
|
||||
end
|
||||
end
|
||||
|
||||
if self.scriptability == "Custom" then
|
||||
local interface = customProperties[self.className][self.name]
|
||||
|
||||
return interface.read(instance, self.name)
|
||||
end
|
||||
|
||||
if self.scriptability == "None" or self.scriptability == "Write" then
|
||||
local fullName = ("%s.%s"):format(instance.className, self.name)
|
||||
|
||||
return false, Error.new(Error.Kind.PropertyNotReadable, fullName)
|
||||
end
|
||||
|
||||
error(("Internal error: unexpected value of 'scriptability': %s"):format(tostring(self.scriptability)), 2)
|
||||
end
|
||||
|
||||
function PropertyDescriptor:write(instance, value)
|
||||
if self.scriptability == "ReadWrite" or self.scriptability == "Write" then
|
||||
local success, err = xpcall(set, debug.traceback, instance, self.name, value)
|
||||
|
||||
if success then
|
||||
return success
|
||||
else
|
||||
return false, Error.new(Error.Kind.Roblox, err)
|
||||
end
|
||||
end
|
||||
|
||||
if self.scriptability == "Custom" then
|
||||
local interface = customProperties[self.className][self.name]
|
||||
|
||||
return interface.write(instance, self.name, value)
|
||||
end
|
||||
|
||||
if self.scriptability == "None" or self.scriptability == "Read" then
|
||||
local fullName = ("%s.%s"):format(instance.className, self.name)
|
||||
|
||||
return false, Error.new(Error.Kind.PropertyNotWritable, fullName)
|
||||
end
|
||||
end
|
||||
|
||||
return PropertyDescriptor
|
||||
19583
plugin/rbx_dom_lua/src/ReflectionDatabase/classes.lua
Normal file
19583
plugin/rbx_dom_lua/src/ReflectionDatabase/classes.lua
Normal file
File diff suppressed because it is too large
Load Diff
3
plugin/rbx_dom_lua/src/ReflectionDatabase/init.lua
Normal file
3
plugin/rbx_dom_lua/src/ReflectionDatabase/init.lua
Normal file
@@ -0,0 +1,3 @@
|
||||
return {
|
||||
classes = require(script.classes)
|
||||
}
|
||||
139
plugin/rbx_dom_lua/src/base64.lua
Normal file
139
plugin/rbx_dom_lua/src/base64.lua
Normal file
@@ -0,0 +1,139 @@
|
||||
-- Thanks to Tiffany352 for this base64 implementation!
|
||||
|
||||
local floor = math.floor
|
||||
local char = string.char
|
||||
|
||||
local function encodeBase64(str)
|
||||
local out = {}
|
||||
local nOut = 0
|
||||
local alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||
local strLen = #str
|
||||
|
||||
-- 3 octets become 4 hextets
|
||||
for i = 1, strLen - 2, 3 do
|
||||
local b1, b2, b3 = str:byte(i, i + 3)
|
||||
local word = b3 + b2 * 256 + b1 * 256 * 256
|
||||
|
||||
local h4 = word % 64 + 1
|
||||
word = floor(word / 64)
|
||||
local h3 = word % 64 + 1
|
||||
word = floor(word / 64)
|
||||
local h2 = word % 64 + 1
|
||||
word = floor(word / 64)
|
||||
local h1 = word % 64 + 1
|
||||
|
||||
out[nOut + 1] = alphabet:sub(h1, h1)
|
||||
out[nOut + 2] = alphabet:sub(h2, h2)
|
||||
out[nOut + 3] = alphabet:sub(h3, h3)
|
||||
out[nOut + 4] = alphabet:sub(h4, h4)
|
||||
nOut = nOut + 4
|
||||
end
|
||||
|
||||
local remainder = strLen % 3
|
||||
|
||||
if remainder == 2 then
|
||||
-- 16 input bits -> 3 hextets (2 full, 1 partial)
|
||||
local b1, b2 = str:byte(-2, -1)
|
||||
-- partial is 4 bits long, leaving 2 bits of zero padding ->
|
||||
-- offset = 4
|
||||
local word = b2 * 4 + b1 * 4 * 256
|
||||
|
||||
local h3 = word % 64 + 1
|
||||
word = floor(word / 64)
|
||||
local h2 = word % 64 + 1
|
||||
word = floor(word / 64)
|
||||
local h1 = word % 64 + 1
|
||||
|
||||
out[nOut + 1] = alphabet:sub(h1, h1)
|
||||
out[nOut + 2] = alphabet:sub(h2, h2)
|
||||
out[nOut + 3] = alphabet:sub(h3, h3)
|
||||
out[nOut + 4] = "="
|
||||
elseif remainder == 1 then
|
||||
-- 8 input bits -> 2 hextets (2 full, 1 partial)
|
||||
local b1 = str:byte(-1, -1)
|
||||
-- partial is 2 bits long, leaving 4 bits of zero padding ->
|
||||
-- offset = 16
|
||||
local word = b1 * 16
|
||||
|
||||
local h2 = word % 64 + 1
|
||||
word = floor(word / 64)
|
||||
local h1 = word % 64 + 1
|
||||
|
||||
out[nOut + 1] = alphabet:sub(h1, h1)
|
||||
out[nOut + 2] = alphabet:sub(h2, h2)
|
||||
out[nOut + 3] = "="
|
||||
out[nOut + 4] = "="
|
||||
end
|
||||
-- if the remainder is 0, then no work is needed
|
||||
|
||||
return table.concat(out, "")
|
||||
end
|
||||
|
||||
local function decodeBase64(str)
|
||||
local out = {}
|
||||
local nOut = 0
|
||||
local alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||
local strLen = #str
|
||||
local acc = 0
|
||||
local nAcc = 0
|
||||
|
||||
local alphabetLut = {}
|
||||
for i = 1, #alphabet do
|
||||
alphabetLut[alphabet:sub(i, i)] = i - 1
|
||||
end
|
||||
|
||||
-- 4 hextets become 3 octets
|
||||
for i = 1, strLen do
|
||||
local ch = str:sub(i, i)
|
||||
local byte = alphabetLut[ch]
|
||||
if byte then
|
||||
acc = acc * 64 + byte
|
||||
nAcc = nAcc + 1
|
||||
end
|
||||
|
||||
if nAcc == 4 then
|
||||
local b3 = acc % 256
|
||||
acc = floor(acc / 256)
|
||||
local b2 = acc % 256
|
||||
acc = floor(acc / 256)
|
||||
local b1 = acc % 256
|
||||
|
||||
out[nOut + 1] = char(b1)
|
||||
out[nOut + 2] = char(b2)
|
||||
out[nOut + 3] = char(b3)
|
||||
nOut = nOut + 3
|
||||
nAcc = 0
|
||||
acc = 0
|
||||
end
|
||||
end
|
||||
|
||||
if nAcc == 3 then
|
||||
-- 3 hextets -> 16 bit output
|
||||
acc = acc * 64
|
||||
acc = floor(acc / 256)
|
||||
local b2 = acc % 256
|
||||
acc = floor(acc / 256)
|
||||
local b1 = acc % 256
|
||||
|
||||
out[nOut + 1] = char(b1)
|
||||
out[nOut + 2] = char(b2)
|
||||
elseif nAcc == 2 then
|
||||
-- 2 hextets -> 8 bit output
|
||||
acc = acc * 64
|
||||
acc = floor(acc / 256)
|
||||
acc = acc * 64
|
||||
acc = floor(acc / 256)
|
||||
local b1 = acc % 256
|
||||
|
||||
out[nOut + 1] = char(b1)
|
||||
elseif nAcc == 1 then
|
||||
error("Base64 has invalid length")
|
||||
end
|
||||
|
||||
return table.concat(out, "")
|
||||
end
|
||||
|
||||
return {
|
||||
decode = decodeBase64,
|
||||
encode = encodeBase64,
|
||||
}
|
||||
29
plugin/rbx_dom_lua/src/base64.spec.lua
Normal file
29
plugin/rbx_dom_lua/src/base64.spec.lua
Normal file
@@ -0,0 +1,29 @@
|
||||
return function()
|
||||
local base64 = require(script.Parent.base64)
|
||||
|
||||
it("should encode and decode", function()
|
||||
local function try(str, expected)
|
||||
local encoded = base64.encode(str)
|
||||
expect(encoded).to.equal(expected)
|
||||
expect(base64.decode(encoded)).to.equal(str)
|
||||
end
|
||||
|
||||
try("Man", "TWFu")
|
||||
try("Ma", "TWE=")
|
||||
try("M", "TQ==")
|
||||
try("ManM", "TWFuTQ==")
|
||||
try(
|
||||
[[Man is distinguished, not only by his reason, but by this ]]..
|
||||
[[singular passion from other animals, which is a lust of the ]]..
|
||||
[[mind, that by a perseverance of delight in the continued and ]]..
|
||||
[[indefatigable generation of knowledge, exceeds the short ]]..
|
||||
[[vehemence of any carnal pleasure.]],
|
||||
[[TWFuIGlzIGRpc3Rpbmd1aXNoZWQsIG5vdCBvbmx5IGJ5IGhpcyByZWFzb24sI]]..
|
||||
[[GJ1dCBieSB0aGlzIHNpbmd1bGFyIHBhc3Npb24gZnJvbSBvdGhlciBhbmltYW]]..
|
||||
[[xzLCB3aGljaCBpcyBhIGx1c3Qgb2YgdGhlIG1pbmQsIHRoYXQgYnkgYSBwZXJ]]..
|
||||
[[zZXZlcmFuY2Ugb2YgZGVsaWdodCBpbiB0aGUgY29udGludWVkIGFuZCBpbmRl]]..
|
||||
[[ZmF0aWdhYmxlIGdlbmVyYXRpb24gb2Yga25vd2xlZGdlLCBleGNlZWRzIHRoZ]]..
|
||||
[[SBzaG9ydCB2ZWhlbWVuY2Ugb2YgYW55IGNhcm5hbCBwbGVhc3VyZS4=]]
|
||||
)
|
||||
end)
|
||||
end
|
||||
47
plugin/rbx_dom_lua/src/customProperties.lua
Normal file
47
plugin/rbx_dom_lua/src/customProperties.lua
Normal file
@@ -0,0 +1,47 @@
|
||||
local CollectionService = game:GetService("CollectionService")
|
||||
|
||||
-- Defines how to read and write properties that aren't directly scriptable.
|
||||
--
|
||||
-- The reflection database refers to these as having scriptability = "Custom"
|
||||
return {
|
||||
Instance = {
|
||||
Tags = {
|
||||
read = function(instance, key)
|
||||
local tagList = CollectionService:GetTags(instance)
|
||||
|
||||
return true, table.concat(tagList, "\0")
|
||||
end,
|
||||
write = function(instance, key, value)
|
||||
local existingTags = CollectionService:GetTags(instance)
|
||||
|
||||
local unseenTags = {}
|
||||
for _, tag in ipairs(existingTags) do
|
||||
unseenTags[tag] = true
|
||||
end
|
||||
|
||||
local tagList = string.split(value, "\0")
|
||||
for _, tag in ipairs(tagList) do
|
||||
unseenTags[tag] = nil
|
||||
CollectionService:AddTag(instance, tag)
|
||||
end
|
||||
|
||||
for tag in pairs(unseenTags) do
|
||||
CollectionService:RemoveTag(instance, tag)
|
||||
end
|
||||
|
||||
return true
|
||||
end,
|
||||
},
|
||||
},
|
||||
LocalizationTable = {
|
||||
Contents = {
|
||||
read = function(instance, key)
|
||||
return true, instance:GetContents()
|
||||
end,
|
||||
write = function(instance, key, value)
|
||||
instance:SetContents(value)
|
||||
return true
|
||||
end,
|
||||
},
|
||||
},
|
||||
}
|
||||
67
plugin/rbx_dom_lua/src/init.lua
Normal file
67
plugin/rbx_dom_lua/src/init.lua
Normal file
@@ -0,0 +1,67 @@
|
||||
local ReflectionDatabase = require(script.ReflectionDatabase)
|
||||
local Error = require(script.Error)
|
||||
local PropertyDescriptor = require(script.PropertyDescriptor)
|
||||
|
||||
local function findCanonicalPropertyDescriptor(className, propertyName)
|
||||
local currentClassName = className
|
||||
|
||||
repeat
|
||||
local currentClass = ReflectionDatabase.classes[currentClassName]
|
||||
|
||||
if currentClass == nil then
|
||||
return currentClass
|
||||
end
|
||||
|
||||
local propertyData = currentClass.properties[propertyName]
|
||||
if propertyData ~= nil then
|
||||
if propertyData.isCanonical then
|
||||
return PropertyDescriptor.fromRaw(propertyData, currentClassName, propertyName)
|
||||
end
|
||||
|
||||
if propertyData.canonicalName ~= nil then
|
||||
return PropertyDescriptor.fromRaw(
|
||||
currentClass.properties[propertyData.canonicalName],
|
||||
currentClassName,
|
||||
propertyData.canonicalName)
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
currentClassName = currentClass.superclass
|
||||
until currentClassName == nil
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
local function readProperty(instance, propertyName)
|
||||
local descriptor = findCanonicalPropertyDescriptor(instance.ClassName, propertyName)
|
||||
|
||||
if descriptor == nil then
|
||||
local fullName = ("%s.%s"):format(instance.className, propertyName)
|
||||
|
||||
return false, Error.new(Error.Kind.UnknownProperty, fullName)
|
||||
end
|
||||
|
||||
return descriptor:read(instance)
|
||||
end
|
||||
|
||||
local function writeProperty(instance, propertyName, value)
|
||||
local descriptor = findCanonicalPropertyDescriptor(instance.ClassName, propertyName)
|
||||
|
||||
if descriptor == nil then
|
||||
local fullName = ("%s.%s"):format(instance.className, propertyName)
|
||||
|
||||
return false, Error.new(Error.Kind.UnknownProperty, fullName)
|
||||
end
|
||||
|
||||
return descriptor:write(instance, value)
|
||||
end
|
||||
|
||||
return {
|
||||
readProperty = readProperty,
|
||||
writeProperty = writeProperty,
|
||||
findCanonicalPropertyDescriptor = findCanonicalPropertyDescriptor,
|
||||
Error = Error,
|
||||
EncodedValue = require(script.EncodedValue),
|
||||
}
|
||||
7
plugin/rbx_dom_lua/src/init.spec.lua
Normal file
7
plugin/rbx_dom_lua/src/init.spec.lua
Normal file
@@ -0,0 +1,7 @@
|
||||
return function()
|
||||
local RbxDom = require(script.Parent)
|
||||
|
||||
it("should load", function()
|
||||
expect(RbxDom).to.be.ok()
|
||||
end)
|
||||
end
|
||||
35
plugin/rbx_dom_lua/test-place.project.json
Normal file
35
plugin/rbx_dom_lua/test-place.project.json
Normal file
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "rbx_dom_lua test place",
|
||||
"tree": {
|
||||
"$className": "DataModel",
|
||||
"ReplicatedStorage": {
|
||||
"$className": "ReplicatedStorage",
|
||||
|
||||
"RbxDom": {
|
||||
"$path": "src"
|
||||
},
|
||||
"TestEZ": {
|
||||
"$path": "modules/testez/lib"
|
||||
}
|
||||
},
|
||||
"ServerScriptService": {
|
||||
"$className": "ServerScriptService",
|
||||
|
||||
"Run Tests": {
|
||||
"$path": "test.server.lua"
|
||||
}
|
||||
},
|
||||
"Players": {
|
||||
"$className": "Players",
|
||||
"$properties": {
|
||||
"CharacterAutoLoads": false
|
||||
}
|
||||
},
|
||||
"HttpService": {
|
||||
"$className": "HttpService",
|
||||
"$properties": {
|
||||
"HttpEnabled": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
7
plugin/rbx_dom_lua/test.server.lua
Normal file
7
plugin/rbx_dom_lua/test.server.lua
Normal file
@@ -0,0 +1,7 @@
|
||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local LIB_ROOT = ReplicatedStorage.RbxDom
|
||||
|
||||
local TestEZ = require(ReplicatedStorage.TestEZ)
|
||||
|
||||
TestEZ.TestBootstrap:run({LIB_ROOT})
|
||||
@@ -233,4 +233,19 @@ function ApiContext:retrieveMessages()
|
||||
end)
|
||||
end
|
||||
|
||||
function ApiContext:open(id)
|
||||
local url = ("%s/api/open/%s"):format(self.__baseUrl, id)
|
||||
|
||||
return Http.post(url, "")
|
||||
:andThen(rejectFailedRequests)
|
||||
:andThen(Http.Response.json)
|
||||
:andThen(function(body)
|
||||
if body.sessionId ~= self.__sessionId then
|
||||
return Promise.reject("Server changed ID")
|
||||
end
|
||||
|
||||
return nil
|
||||
end)
|
||||
end
|
||||
|
||||
return ApiContext
|
||||
@@ -17,6 +17,7 @@ local ConnectPanel = require(Plugin.Components.ConnectPanel)
|
||||
local ConnectingPanel = require(Plugin.Components.ConnectingPanel)
|
||||
local ConnectionActivePanel = require(Plugin.Components.ConnectionActivePanel)
|
||||
local ErrorPanel = require(Plugin.Components.ErrorPanel)
|
||||
local SettingsPanel = require(Plugin.Components.SettingsPanel)
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
@@ -61,6 +62,7 @@ local AppStatus = strict("AppStatus", {
|
||||
Connecting = "Connecting",
|
||||
Connected = "Connected",
|
||||
Error = "Error",
|
||||
Settings = "Settings",
|
||||
})
|
||||
|
||||
local App = Roact.Component:extend("App")
|
||||
@@ -73,10 +75,7 @@ function App:init()
|
||||
|
||||
self.signals = {}
|
||||
self.serveSession = nil
|
||||
|
||||
self.displayedVersion = DevSettings:isEnabled()
|
||||
and Config.codename
|
||||
or Version.display(Config.version)
|
||||
self.displayedVersion = Version.display(Config.version)
|
||||
|
||||
local toolbar = self.props.plugin:CreateToolbar("Rojo " .. self.displayedVersion)
|
||||
|
||||
@@ -108,12 +107,14 @@ function App:init()
|
||||
end)
|
||||
end
|
||||
|
||||
function App:startSession(address, port)
|
||||
function App:startSession(address, port, sessionOptions)
|
||||
Log.trace("Starting new session")
|
||||
|
||||
local baseUrl = ("http://%s:%s"):format(address, port)
|
||||
self.serveSession = ServeSession.new({
|
||||
apiContext = ApiContext.new(baseUrl),
|
||||
openScriptsExternally = sessionOptions.openScriptsExternally,
|
||||
twoWaySync = sessionOptions.twoWaySync,
|
||||
})
|
||||
|
||||
self.serveSession:onStatusChanged(function(status, details)
|
||||
@@ -154,8 +155,13 @@ function App:render()
|
||||
if self.state.appStatus == AppStatus.NotStarted then
|
||||
children = {
|
||||
ConnectPanel = e(ConnectPanel, {
|
||||
startSession = function(address, port)
|
||||
self:startSession(address, port)
|
||||
startSession = function(address, port, settings)
|
||||
self:startSession(address, port, settings)
|
||||
end,
|
||||
openSettings = function()
|
||||
self:setState({
|
||||
appStatus = AppStatus.Settings,
|
||||
})
|
||||
end,
|
||||
cancel = function()
|
||||
Log.trace("Canceling session configuration")
|
||||
@@ -168,7 +174,7 @@ function App:render()
|
||||
}
|
||||
elseif self.state.appStatus == AppStatus.Connecting then
|
||||
children = {
|
||||
ConnectingPanel = Roact.createElement(ConnectingPanel),
|
||||
ConnectingPanel = e(ConnectingPanel),
|
||||
}
|
||||
elseif self.state.appStatus == AppStatus.Connected then
|
||||
children = {
|
||||
@@ -186,9 +192,19 @@ function App:render()
|
||||
end,
|
||||
}),
|
||||
}
|
||||
elseif self.state.appStatus == AppStatus.Settings then
|
||||
children = {
|
||||
e(SettingsPanel, {
|
||||
back = function()
|
||||
self:setState({
|
||||
appStatus = AppStatus.NotStarted,
|
||||
})
|
||||
end,
|
||||
}),
|
||||
}
|
||||
elseif self.state.appStatus == AppStatus.Error then
|
||||
children = {
|
||||
ErrorPanel = Roact.createElement(ErrorPanel, {
|
||||
ErrorPanel = e(ErrorPanel, {
|
||||
errorMessage = self.state.errorMessage,
|
||||
onDismiss = function()
|
||||
self:setState({
|
||||
@@ -199,7 +215,7 @@ function App:render()
|
||||
}
|
||||
end
|
||||
|
||||
return Roact.createElement(Roact.Portal, {
|
||||
return e(Roact.Portal, {
|
||||
target = self.dockWidget,
|
||||
}, children)
|
||||
end
|
||||
|
||||
39
plugin/src/Components/Checkbox.lua
Normal file
39
plugin/src/Components/Checkbox.lua
Normal file
@@ -0,0 +1,39 @@
|
||||
local Rojo = script:FindFirstAncestor("Rojo")
|
||||
local Plugin = Rojo.Plugin
|
||||
|
||||
local Roact = require(Rojo.Roact)
|
||||
|
||||
local Theme = require(Plugin.Components.Theme)
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
local function Checkbox(props)
|
||||
local checked = props.checked
|
||||
local layoutOrder = props.layoutOrder
|
||||
local onChange = props.onChange
|
||||
|
||||
return Theme.with(function(theme)
|
||||
return e("ImageButton", {
|
||||
LayoutOrder = layoutOrder,
|
||||
Size = UDim2.new(0, 20, 0, 20),
|
||||
BorderSizePixel = 2,
|
||||
BorderColor3 = theme.Text2,
|
||||
BackgroundColor3 = theme.Background2,
|
||||
|
||||
[Roact.Event.Activated] = function()
|
||||
onChange(not checked)
|
||||
end,
|
||||
}, {
|
||||
Indicator = e("Frame", {
|
||||
Size = UDim2.new(0, 18, 0, 18),
|
||||
Position = UDim2.new(0.5, 0, 0.5, 0),
|
||||
AnchorPoint = Vector2.new(0.5, 0.5),
|
||||
BorderSizePixel = 0,
|
||||
BackgroundColor3 = theme.Brand1,
|
||||
BackgroundTransparency = checked and 0 or 1,
|
||||
})
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
return Checkbox
|
||||
@@ -4,13 +4,14 @@ local Plugin = Rojo.Plugin
|
||||
local Roact = require(Rojo.Roact)
|
||||
|
||||
local Config = require(Plugin.Config)
|
||||
local Theme = require(Plugin.Theme)
|
||||
|
||||
local Theme = require(Plugin.Components.Theme)
|
||||
local Panel = require(Plugin.Components.Panel)
|
||||
local FitList = require(Plugin.Components.FitList)
|
||||
local FitText = require(Plugin.Components.FitText)
|
||||
local FormButton = require(Plugin.Components.FormButton)
|
||||
local FormTextInput = require(Plugin.Components.FormTextInput)
|
||||
local PluginSettings = require(Plugin.Components.PluginSettings)
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
@@ -25,137 +26,158 @@ end
|
||||
|
||||
function ConnectPanel:render()
|
||||
local startSession = self.props.startSession
|
||||
local openSettings = self.props.openSettings
|
||||
|
||||
return e(Panel, nil, {
|
||||
Layout = e("UIListLayout", {
|
||||
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||
HorizontalAlignment = Enum.HorizontalAlignment.Center,
|
||||
VerticalAlignment = Enum.VerticalAlignment.Center,
|
||||
}),
|
||||
|
||||
Inputs = e(FitList, {
|
||||
containerProps = {
|
||||
BackgroundTransparency = 1,
|
||||
LayoutOrder = 1,
|
||||
},
|
||||
layoutProps = {
|
||||
FillDirection = Enum.FillDirection.Horizontal,
|
||||
Padding = UDim.new(0, 8),
|
||||
},
|
||||
paddingProps = {
|
||||
PaddingTop = UDim.new(0, 20),
|
||||
PaddingBottom = UDim.new(0, 10),
|
||||
PaddingLeft = UDim.new(0, 24),
|
||||
PaddingRight = UDim.new(0, 24),
|
||||
},
|
||||
}, {
|
||||
Address = e(FitList, {
|
||||
containerProps = {
|
||||
LayoutOrder = 1,
|
||||
BackgroundTransparency = 1,
|
||||
},
|
||||
layoutProps = {
|
||||
Padding = UDim.new(0, 4),
|
||||
},
|
||||
}, {
|
||||
Label = e(FitText, {
|
||||
Kind = "TextLabel",
|
||||
LayoutOrder = 1,
|
||||
BackgroundTransparency = 1,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
Font = Theme.TitleFont,
|
||||
TextSize = 20,
|
||||
Text = "Address",
|
||||
TextColor3 = Theme.PrimaryColor,
|
||||
return Theme.with(function(theme)
|
||||
return PluginSettings.with(function(settings)
|
||||
return e(Panel, nil, {
|
||||
Layout = e("UIListLayout", {
|
||||
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||
HorizontalAlignment = Enum.HorizontalAlignment.Center,
|
||||
VerticalAlignment = Enum.VerticalAlignment.Center,
|
||||
}),
|
||||
|
||||
Input = e(FormTextInput, {
|
||||
layoutOrder = 2,
|
||||
width = UDim.new(0, 220),
|
||||
value = self.state.address,
|
||||
placeholderValue = Config.defaultHost,
|
||||
onValueChange = function(newValue)
|
||||
self:setState({
|
||||
address = newValue,
|
||||
})
|
||||
end,
|
||||
}),
|
||||
}),
|
||||
Inputs = e(FitList, {
|
||||
containerProps = {
|
||||
BackgroundTransparency = 1,
|
||||
LayoutOrder = 1,
|
||||
},
|
||||
layoutProps = {
|
||||
FillDirection = Enum.FillDirection.Horizontal,
|
||||
Padding = UDim.new(0, 8),
|
||||
},
|
||||
paddingProps = {
|
||||
PaddingTop = UDim.new(0, 20),
|
||||
PaddingBottom = UDim.new(0, 10),
|
||||
PaddingLeft = UDim.new(0, 24),
|
||||
PaddingRight = UDim.new(0, 24),
|
||||
},
|
||||
}, {
|
||||
Address = e(FitList, {
|
||||
containerProps = {
|
||||
LayoutOrder = 1,
|
||||
BackgroundTransparency = 1,
|
||||
},
|
||||
layoutProps = {
|
||||
Padding = UDim.new(0, 4),
|
||||
},
|
||||
}, {
|
||||
Label = e(FitText, {
|
||||
Kind = "TextLabel",
|
||||
LayoutOrder = 1,
|
||||
BackgroundTransparency = 1,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
Font = theme.TitleFont,
|
||||
TextSize = 20,
|
||||
Text = "Address",
|
||||
TextColor3 = theme.Text1,
|
||||
}),
|
||||
|
||||
Port = e(FitList, {
|
||||
containerProps = {
|
||||
LayoutOrder = 2,
|
||||
BackgroundTransparency = 1,
|
||||
},
|
||||
layoutProps = {
|
||||
Padding = UDim.new(0, 4),
|
||||
},
|
||||
}, {
|
||||
Label = e(FitText, {
|
||||
Kind = "TextLabel",
|
||||
LayoutOrder = 1,
|
||||
BackgroundTransparency = 1,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
Font = Theme.TitleFont,
|
||||
TextSize = 20,
|
||||
Text = "Port",
|
||||
TextColor3 = Theme.PrimaryColor,
|
||||
Input = e(FormTextInput, {
|
||||
layoutOrder = 2,
|
||||
width = UDim.new(0, 220),
|
||||
value = self.state.address,
|
||||
placeholderValue = Config.defaultHost,
|
||||
onValueChange = function(newValue)
|
||||
self:setState({
|
||||
address = newValue,
|
||||
})
|
||||
end,
|
||||
}),
|
||||
}),
|
||||
|
||||
Port = e(FitList, {
|
||||
containerProps = {
|
||||
LayoutOrder = 2,
|
||||
BackgroundTransparency = 1,
|
||||
},
|
||||
layoutProps = {
|
||||
Padding = UDim.new(0, 4),
|
||||
},
|
||||
}, {
|
||||
Label = e(FitText, {
|
||||
Kind = "TextLabel",
|
||||
LayoutOrder = 1,
|
||||
BackgroundTransparency = 1,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
Font = theme.TitleFont,
|
||||
TextSize = 20,
|
||||
Text = "Port",
|
||||
TextColor3 = theme.Text1,
|
||||
}),
|
||||
|
||||
Input = e(FormTextInput, {
|
||||
layoutOrder = 2,
|
||||
width = UDim.new(0, 80),
|
||||
value = self.state.port,
|
||||
placeholderValue = Config.defaultPort,
|
||||
onValueChange = function(newValue)
|
||||
self:setState({
|
||||
port = newValue,
|
||||
})
|
||||
end,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
|
||||
Input = e(FormTextInput, {
|
||||
layoutOrder = 2,
|
||||
width = UDim.new(0, 80),
|
||||
value = self.state.port,
|
||||
placeholderValue = Config.defaultPort,
|
||||
onValueChange = function(newValue)
|
||||
self:setState({
|
||||
port = newValue,
|
||||
})
|
||||
end,
|
||||
Buttons = e(FitList, {
|
||||
fitAxes = "Y",
|
||||
containerProps = {
|
||||
BackgroundTransparency = 1,
|
||||
LayoutOrder = 2,
|
||||
Size = UDim2.new(1, 0, 0, 0),
|
||||
},
|
||||
layoutProps = {
|
||||
FillDirection = Enum.FillDirection.Horizontal,
|
||||
HorizontalAlignment = Enum.HorizontalAlignment.Right,
|
||||
Padding = UDim.new(0, 8),
|
||||
},
|
||||
paddingProps = {
|
||||
PaddingTop = UDim.new(0, 0),
|
||||
PaddingBottom = UDim.new(0, 20),
|
||||
PaddingLeft = UDim.new(0, 24),
|
||||
PaddingRight = UDim.new(0, 24),
|
||||
},
|
||||
}, {
|
||||
e(FormButton, {
|
||||
layoutOrder = 1,
|
||||
text = "Settings",
|
||||
secondary = true,
|
||||
onClick = function()
|
||||
if openSettings ~= nil then
|
||||
openSettings()
|
||||
end
|
||||
end,
|
||||
}),
|
||||
|
||||
e(FormButton, {
|
||||
layoutOrder = 2,
|
||||
text = "Connect",
|
||||
onClick = function()
|
||||
if startSession ~= nil then
|
||||
local address = self.state.address
|
||||
if address:len() == 0 then
|
||||
address = Config.defaultHost
|
||||
end
|
||||
|
||||
local port = self.state.port
|
||||
if port:len() == 0 then
|
||||
port = Config.defaultPort
|
||||
end
|
||||
|
||||
local sessionOptions = {
|
||||
openScriptsExternally = settings:get("openScriptsExternally"),
|
||||
twoWaySync = settings:get("twoWaySync"),
|
||||
}
|
||||
|
||||
startSession(address, port, sessionOptions)
|
||||
end
|
||||
end,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
|
||||
Buttons = e(FitList, {
|
||||
fitAxes = "Y",
|
||||
containerProps = {
|
||||
BackgroundTransparency = 1,
|
||||
LayoutOrder = 2,
|
||||
Size = UDim2.new(1, 0, 0, 0),
|
||||
},
|
||||
layoutProps = {
|
||||
FillDirection = Enum.FillDirection.Horizontal,
|
||||
HorizontalAlignment = Enum.HorizontalAlignment.Right,
|
||||
Padding = UDim.new(0, 8),
|
||||
},
|
||||
paddingProps = {
|
||||
PaddingTop = UDim.new(0, 0),
|
||||
PaddingBottom = UDim.new(0, 20),
|
||||
PaddingLeft = UDim.new(0, 24),
|
||||
PaddingRight = UDim.new(0, 24),
|
||||
},
|
||||
}, {
|
||||
e(FormButton, {
|
||||
layoutOrder = 2,
|
||||
text = "Connect",
|
||||
onClick = function()
|
||||
if startSession ~= nil then
|
||||
local address = self.state.address
|
||||
if address:len() == 0 then
|
||||
address = Config.defaultHost
|
||||
end
|
||||
|
||||
local port = self.state.port
|
||||
if port:len() == 0 then
|
||||
port = Config.defaultPort
|
||||
end
|
||||
|
||||
startSession(address, port)
|
||||
end
|
||||
end,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
})
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
return ConnectPanel
|
||||
@@ -2,8 +2,7 @@ local Roact = require(script:FindFirstAncestor("Rojo").Roact)
|
||||
|
||||
local Plugin = script:FindFirstAncestor("Plugin")
|
||||
|
||||
local Theme = require(Plugin.Theme)
|
||||
|
||||
local Theme = require(Plugin.Components.Theme)
|
||||
local Panel = require(Plugin.Components.Panel)
|
||||
local FitText = require(Plugin.Components.FitText)
|
||||
|
||||
@@ -12,23 +11,25 @@ local e = Roact.createElement
|
||||
local ConnectingPanel = Roact.Component:extend("ConnectingPanel")
|
||||
|
||||
function ConnectingPanel:render()
|
||||
return e(Panel, nil, {
|
||||
Layout = Roact.createElement("UIListLayout", {
|
||||
HorizontalAlignment = Enum.HorizontalAlignment.Center,
|
||||
VerticalAlignment = Enum.VerticalAlignment.Center,
|
||||
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||
Padding = UDim.new(0, 8),
|
||||
}),
|
||||
return Theme.with(function(theme)
|
||||
return e(Panel, nil, {
|
||||
Layout = Roact.createElement("UIListLayout", {
|
||||
HorizontalAlignment = Enum.HorizontalAlignment.Center,
|
||||
VerticalAlignment = Enum.VerticalAlignment.Center,
|
||||
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||
Padding = UDim.new(0, 8),
|
||||
}),
|
||||
|
||||
Text = e(FitText, {
|
||||
Padding = Vector2.new(12, 6),
|
||||
Font = Theme.ButtonFont,
|
||||
TextSize = 18,
|
||||
Text = "Connecting...",
|
||||
TextColor3 = Theme.PrimaryColor,
|
||||
BackgroundTransparency = 1,
|
||||
}),
|
||||
})
|
||||
Text = e(FitText, {
|
||||
Padding = Vector2.new(12, 6),
|
||||
Font = theme.ButtonFont,
|
||||
TextSize = 18,
|
||||
Text = "Connecting...",
|
||||
TextColor3 = theme.Text1,
|
||||
BackgroundTransparency = 1,
|
||||
}),
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
return ConnectingPanel
|
||||
@@ -2,8 +2,7 @@ local Roact = require(script:FindFirstAncestor("Rojo").Roact)
|
||||
|
||||
local Plugin = script:FindFirstAncestor("Plugin")
|
||||
|
||||
local Theme = require(Plugin.Theme)
|
||||
|
||||
local Theme = require(Plugin.Components.Theme)
|
||||
local Panel = require(Plugin.Components.Panel)
|
||||
local FitText = require(Plugin.Components.FitText)
|
||||
local FormButton = require(Plugin.Components.FormButton)
|
||||
@@ -15,32 +14,34 @@ local ConnectionActivePanel = Roact.Component:extend("ConnectionActivePanel")
|
||||
function ConnectionActivePanel:render()
|
||||
local stopSession = self.props.stopSession
|
||||
|
||||
return e(Panel, nil, {
|
||||
Layout = Roact.createElement("UIListLayout", {
|
||||
HorizontalAlignment = Enum.HorizontalAlignment.Center,
|
||||
VerticalAlignment = Enum.VerticalAlignment.Center,
|
||||
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||
Padding = UDim.new(0, 8),
|
||||
}),
|
||||
return Theme.with(function(theme)
|
||||
return e(Panel, nil, {
|
||||
Layout = Roact.createElement("UIListLayout", {
|
||||
HorizontalAlignment = Enum.HorizontalAlignment.Center,
|
||||
VerticalAlignment = Enum.VerticalAlignment.Center,
|
||||
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||
Padding = UDim.new(0, 8),
|
||||
}),
|
||||
|
||||
Text = e(FitText, {
|
||||
Padding = Vector2.new(12, 6),
|
||||
Font = Theme.ButtonFont,
|
||||
TextSize = 18,
|
||||
Text = "Connected to Live-Sync Server",
|
||||
TextColor3 = Theme.PrimaryColor,
|
||||
BackgroundTransparency = 1,
|
||||
}),
|
||||
Text = e(FitText, {
|
||||
Padding = Vector2.new(12, 6),
|
||||
Font = theme.ButtonFont,
|
||||
TextSize = 18,
|
||||
Text = "Connected to Live-Sync Server",
|
||||
TextColor3 = theme.Text1,
|
||||
BackgroundTransparency = 1,
|
||||
}),
|
||||
|
||||
DisconnectButton = e(FormButton, {
|
||||
layoutOrder = 2,
|
||||
text = "Disconnect",
|
||||
secondary = true,
|
||||
onClick = function()
|
||||
stopSession()
|
||||
end,
|
||||
}),
|
||||
})
|
||||
DisconnectButton = e(FormButton, {
|
||||
layoutOrder = 2,
|
||||
text = "Disconnect",
|
||||
secondary = true,
|
||||
onClick = function()
|
||||
stopSession()
|
||||
end,
|
||||
}),
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
return ConnectionActivePanel
|
||||
@@ -2,8 +2,7 @@ local Roact = require(script:FindFirstAncestor("Rojo").Roact)
|
||||
|
||||
local Plugin = script:FindFirstAncestor("Plugin")
|
||||
|
||||
local Theme = require(Plugin.Theme)
|
||||
|
||||
local Theme = require(Plugin.Components.Theme)
|
||||
local Panel = require(Plugin.Components.Panel)
|
||||
local FitText = require(Plugin.Components.FitText)
|
||||
local FitScrollingFrame = require(Plugin.Components.FitScrollingFrame)
|
||||
@@ -20,50 +19,52 @@ function ErrorPanel:render()
|
||||
local errorMessage = self.props.errorMessage
|
||||
local onDismiss = self.props.onDismiss
|
||||
|
||||
return e(Panel, nil, {
|
||||
Layout = Roact.createElement("UIListLayout", {
|
||||
HorizontalAlignment = Enum.HorizontalAlignment.Center,
|
||||
VerticalAlignment = Enum.VerticalAlignment.Center,
|
||||
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||
Padding = UDim.new(0, 8),
|
||||
}),
|
||||
|
||||
ErrorContainer = e(FitScrollingFrame, {
|
||||
containerProps = {
|
||||
BackgroundTransparency = 1,
|
||||
BorderSizePixel = 0,
|
||||
Size = UDim2.new(1, -HOR_PADDING * 2, 1, -BUTTON_HEIGHT),
|
||||
Position = UDim2.new(0, HOR_PADDING, 0, 0),
|
||||
ScrollBarImageColor3 = Theme.PrimaryColor,
|
||||
VerticalScrollBarInset = Enum.ScrollBarInset.ScrollBar,
|
||||
ScrollingDirection = Enum.ScrollingDirection.Y,
|
||||
},
|
||||
}, {
|
||||
Text = e(FitText, {
|
||||
Size = UDim2.new(1, 0, 0, 0),
|
||||
|
||||
LayoutOrder = 1,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextYAlignment = Enum.TextYAlignment.Top,
|
||||
FitAxis = "Y",
|
||||
Font = Theme.ButtonFont,
|
||||
TextSize = 18,
|
||||
Text = errorMessage,
|
||||
TextWrap = true,
|
||||
TextColor3 = Theme.PrimaryColor,
|
||||
BackgroundTransparency = 1,
|
||||
return Theme.with(function(theme)
|
||||
return e(Panel, nil, {
|
||||
Layout = Roact.createElement("UIListLayout", {
|
||||
HorizontalAlignment = Enum.HorizontalAlignment.Center,
|
||||
VerticalAlignment = Enum.VerticalAlignment.Center,
|
||||
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||
Padding = UDim.new(0, 8),
|
||||
}),
|
||||
}),
|
||||
|
||||
DismissButton = e(FormButton, {
|
||||
layoutOrder = 2,
|
||||
text = "Dismiss",
|
||||
secondary = true,
|
||||
onClick = function()
|
||||
onDismiss()
|
||||
end,
|
||||
}),
|
||||
})
|
||||
ErrorContainer = e(FitScrollingFrame, {
|
||||
containerProps = {
|
||||
BackgroundTransparency = 1,
|
||||
BorderSizePixel = 0,
|
||||
Size = UDim2.new(1, -HOR_PADDING * 2, 1, -BUTTON_HEIGHT),
|
||||
Position = UDim2.new(0, HOR_PADDING, 0, 0),
|
||||
ScrollBarImageColor3 = theme.Text1,
|
||||
VerticalScrollBarInset = Enum.ScrollBarInset.ScrollBar,
|
||||
ScrollingDirection = Enum.ScrollingDirection.Y,
|
||||
},
|
||||
}, {
|
||||
Text = e(FitText, {
|
||||
Size = UDim2.new(1, 0, 0, 0),
|
||||
|
||||
LayoutOrder = 1,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextYAlignment = Enum.TextYAlignment.Top,
|
||||
FitAxis = "Y",
|
||||
Font = theme.ButtonFont,
|
||||
TextSize = 18,
|
||||
Text = errorMessage,
|
||||
TextWrap = true,
|
||||
TextColor3 = theme.Text1,
|
||||
BackgroundTransparency = 1,
|
||||
}),
|
||||
}),
|
||||
|
||||
DismissButton = e(FormButton, {
|
||||
layoutOrder = 2,
|
||||
text = "Dismiss",
|
||||
secondary = true,
|
||||
onClick = function()
|
||||
onDismiss()
|
||||
end,
|
||||
}),
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
return ErrorPanel
|
||||
@@ -4,7 +4,7 @@ local Plugin = Rojo.Plugin
|
||||
local Roact = require(Rojo.Roact)
|
||||
|
||||
local Assets = require(Plugin.Assets)
|
||||
local Theme = require(Plugin.Theme)
|
||||
local Theme = require(Plugin.Components.Theme)
|
||||
local FitList = require(Plugin.Components.FitList)
|
||||
local FitText = require(Plugin.Components.FitText)
|
||||
|
||||
@@ -20,43 +20,45 @@ local function FormButton(props)
|
||||
local textColor
|
||||
local backgroundColor
|
||||
|
||||
if props.secondary then
|
||||
textColor = Theme.AccentColor
|
||||
backgroundColor = Theme.SecondaryColor
|
||||
else
|
||||
textColor = Theme.SecondaryColor
|
||||
backgroundColor = Theme.AccentColor
|
||||
end
|
||||
return Theme.with(function(theme)
|
||||
if props.secondary then
|
||||
textColor = theme.Brand1
|
||||
backgroundColor = theme.Background2
|
||||
else
|
||||
textColor = theme.TextOnAccent
|
||||
backgroundColor = theme.Brand1
|
||||
end
|
||||
|
||||
return e(FitList, {
|
||||
containerKind = "ImageButton",
|
||||
containerProps = {
|
||||
LayoutOrder = layoutOrder,
|
||||
BackgroundTransparency = 1,
|
||||
Image = RoundBox.asset,
|
||||
ImageRectOffset = RoundBox.offset,
|
||||
ImageRectSize = RoundBox.size,
|
||||
SliceCenter = RoundBox.center,
|
||||
ScaleType = Enum.ScaleType.Slice,
|
||||
ImageColor3 = backgroundColor,
|
||||
return e(FitList, {
|
||||
containerKind = "ImageButton",
|
||||
containerProps = {
|
||||
LayoutOrder = layoutOrder,
|
||||
BackgroundTransparency = 1,
|
||||
Image = RoundBox.asset,
|
||||
ImageRectOffset = RoundBox.offset,
|
||||
ImageRectSize = RoundBox.size,
|
||||
SliceCenter = RoundBox.center,
|
||||
ScaleType = Enum.ScaleType.Slice,
|
||||
ImageColor3 = backgroundColor,
|
||||
|
||||
[Roact.Event.Activated] = function()
|
||||
if onClick ~= nil then
|
||||
onClick()
|
||||
end
|
||||
end,
|
||||
},
|
||||
}, {
|
||||
Text = e(FitText, {
|
||||
Kind = "TextLabel",
|
||||
Text = text,
|
||||
TextSize = 18,
|
||||
TextColor3 = textColor,
|
||||
Font = Theme.ButtonFont,
|
||||
Padding = Vector2.new(16, 8),
|
||||
BackgroundTransparency = 1,
|
||||
}),
|
||||
})
|
||||
[Roact.Event.Activated] = function()
|
||||
if onClick ~= nil then
|
||||
onClick()
|
||||
end
|
||||
end,
|
||||
},
|
||||
}, {
|
||||
Text = e(FitText, {
|
||||
Kind = "TextLabel",
|
||||
Text = text,
|
||||
TextSize = 18,
|
||||
TextColor3 = textColor,
|
||||
Font = theme.ButtonFont,
|
||||
Padding = Vector2.new(16, 8),
|
||||
BackgroundTransparency = 1,
|
||||
}),
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
return FormButton
|
||||
@@ -4,7 +4,7 @@ local Plugin = Rojo.Plugin
|
||||
local Roact = require(Rojo.Roact)
|
||||
|
||||
local Assets = require(Plugin.Assets)
|
||||
local Theme = require(Plugin.Theme)
|
||||
local Theme = require(Plugin.Components.Theme)
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
@@ -35,46 +35,48 @@ function FormTextInput:render()
|
||||
shownPlaceholder = placeholderValue
|
||||
end
|
||||
|
||||
return e("ImageLabel", {
|
||||
LayoutOrder = layoutOrder,
|
||||
Image = RoundBox.asset,
|
||||
ImageRectOffset = RoundBox.offset,
|
||||
ImageRectSize = RoundBox.size,
|
||||
ScaleType = Enum.ScaleType.Slice,
|
||||
SliceCenter = RoundBox.center,
|
||||
ImageColor3 = Theme.SecondaryColor,
|
||||
Size = UDim2.new(width.Scale, width.Offset, 0, TEXT_SIZE + PADDING * 2),
|
||||
BackgroundTransparency = 1,
|
||||
}, {
|
||||
InputInner = e("TextBox", {
|
||||
return Theme.with(function(theme)
|
||||
return e("ImageLabel", {
|
||||
LayoutOrder = layoutOrder,
|
||||
Image = RoundBox.asset,
|
||||
ImageRectOffset = RoundBox.offset,
|
||||
ImageRectSize = RoundBox.size,
|
||||
ScaleType = Enum.ScaleType.Slice,
|
||||
SliceCenter = RoundBox.center,
|
||||
ImageColor3 = theme.Background2,
|
||||
Size = UDim2.new(width.Scale, width.Offset, 0, TEXT_SIZE + PADDING * 2),
|
||||
BackgroundTransparency = 1,
|
||||
Size = UDim2.new(1, -PADDING * 2, 1, -PADDING * 2),
|
||||
Position = UDim2.new(0.5, 0, 0.5, 0),
|
||||
AnchorPoint = Vector2.new(0.5, 0.5),
|
||||
Font = Theme.InputFont,
|
||||
ClearTextOnFocus = false,
|
||||
TextXAlignment = Enum.TextXAlignment.Center,
|
||||
TextSize = TEXT_SIZE,
|
||||
Text = value,
|
||||
PlaceholderText = shownPlaceholder,
|
||||
PlaceholderColor3 = Theme.LightTextColor,
|
||||
TextColor3 = Theme.PrimaryColor,
|
||||
}, {
|
||||
InputInner = e("TextBox", {
|
||||
BackgroundTransparency = 1,
|
||||
Size = UDim2.new(1, -PADDING * 2, 1, -PADDING * 2),
|
||||
Position = UDim2.new(0.5, 0, 0.5, 0),
|
||||
AnchorPoint = Vector2.new(0.5, 0.5),
|
||||
Font = theme.InputFont,
|
||||
ClearTextOnFocus = false,
|
||||
TextXAlignment = Enum.TextXAlignment.Center,
|
||||
TextSize = TEXT_SIZE,
|
||||
Text = value,
|
||||
PlaceholderText = shownPlaceholder,
|
||||
PlaceholderColor3 = theme.Text2,
|
||||
TextColor3 = theme.Text1,
|
||||
|
||||
[Roact.Change.Text] = function(rbx)
|
||||
onValueChange(rbx.Text)
|
||||
end,
|
||||
[Roact.Event.Focused] = function()
|
||||
self:setState({
|
||||
focused = true,
|
||||
})
|
||||
end,
|
||||
[Roact.Event.FocusLost] = function()
|
||||
self:setState({
|
||||
focused = false,
|
||||
})
|
||||
end,
|
||||
}),
|
||||
})
|
||||
[Roact.Change.Text] = function(rbx)
|
||||
onValueChange(rbx.Text)
|
||||
end,
|
||||
[Roact.Event.Focused] = function()
|
||||
self:setState({
|
||||
focused = true,
|
||||
})
|
||||
end,
|
||||
[Roact.Event.FocusLost] = function()
|
||||
self:setState({
|
||||
focused = false,
|
||||
})
|
||||
end,
|
||||
}),
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
return FormTextInput
|
||||
@@ -3,6 +3,7 @@ local Roact = require(script:FindFirstAncestor("Rojo").Roact)
|
||||
local Plugin = script:FindFirstAncestor("Plugin")
|
||||
|
||||
local RojoFooter = require(Plugin.Components.RojoFooter)
|
||||
local Theme = require(Plugin.Components.Theme)
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
@@ -13,22 +14,25 @@ function Panel:init()
|
||||
end
|
||||
|
||||
function Panel:render()
|
||||
return e("Frame", {
|
||||
Size = UDim2.new(1, 0, 1, 0),
|
||||
BackgroundTransparency = 1,
|
||||
}, {
|
||||
Layout = Roact.createElement("UIListLayout", {
|
||||
HorizontalAlignment = Enum.HorizontalAlignment.Center,
|
||||
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||
}),
|
||||
return Theme.with(function(theme)
|
||||
return e("Frame", {
|
||||
Size = UDim2.new(1, 0, 1, 0),
|
||||
BackgroundColor3 = theme.Background1,
|
||||
BorderSizePixel = 1,
|
||||
}, {
|
||||
Layout = Roact.createElement("UIListLayout", {
|
||||
HorizontalAlignment = Enum.HorizontalAlignment.Center,
|
||||
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||
}),
|
||||
|
||||
Body = e("Frame", {
|
||||
Size = UDim2.new(0, 360, 1, -32),
|
||||
BackgroundTransparency = 1,
|
||||
}, self.props[Roact.Children]),
|
||||
Body = e("Frame", {
|
||||
Size = UDim2.new(0, 360, 1, -32),
|
||||
BackgroundTransparency = 1,
|
||||
}, self.props[Roact.Children]),
|
||||
|
||||
Footer = e(RojoFooter),
|
||||
})
|
||||
Footer = e(RojoFooter),
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
return Panel
|
||||
121
plugin/src/Components/PluginSettings.lua
Normal file
121
plugin/src/Components/PluginSettings.lua
Normal file
@@ -0,0 +1,121 @@
|
||||
--[[
|
||||
Persistent plugin settings that can be accessed via Roact context.
|
||||
]]
|
||||
|
||||
local Rojo = script:FindFirstAncestor("Rojo")
|
||||
|
||||
local Roact = require(Rojo.Roact)
|
||||
|
||||
local defaultSettings = {
|
||||
openScriptsExternally = false,
|
||||
twoWaySync = false,
|
||||
}
|
||||
|
||||
local Settings = {}
|
||||
Settings.__index = Settings
|
||||
|
||||
function Settings.fromPlugin(plugin)
|
||||
local values = {}
|
||||
|
||||
for name, defaultValue in pairs(defaultSettings) do
|
||||
local savedValue = plugin:GetSetting("Rojo_" .. name)
|
||||
|
||||
if savedValue == nil then
|
||||
plugin:SetSetting("Rojo_" .. name, defaultValue)
|
||||
values[name] = defaultValue
|
||||
else
|
||||
values[name] = savedValue
|
||||
end
|
||||
end
|
||||
|
||||
return setmetatable({
|
||||
__values = values,
|
||||
__plugin = plugin,
|
||||
__updateListeners = {},
|
||||
}, Settings)
|
||||
end
|
||||
|
||||
function Settings:get(name)
|
||||
if defaultSettings[name] == nil then
|
||||
error("Invalid setings name " .. tostring(name), 2)
|
||||
end
|
||||
|
||||
return self.__values[name]
|
||||
end
|
||||
|
||||
function Settings:set(name, value)
|
||||
self.__plugin:SetSetting("Rojo_" .. name, value)
|
||||
self.__values[name] = value
|
||||
|
||||
for callback in pairs(self.__updateListeners) do
|
||||
callback(name, value)
|
||||
end
|
||||
end
|
||||
|
||||
function Settings:onUpdate(newCallback)
|
||||
local newListeners = {}
|
||||
for callback in pairs(self.__updateListeners) do
|
||||
newListeners[callback] = true
|
||||
end
|
||||
|
||||
newListeners[newCallback] = true
|
||||
self.__updateListeners = newListeners
|
||||
|
||||
return function()
|
||||
local newListeners = {}
|
||||
for callback in pairs(self.__updateListeners) do
|
||||
if callback ~= newCallback then
|
||||
newListeners[callback] = true
|
||||
end
|
||||
end
|
||||
|
||||
self.__updateListeners = newListeners
|
||||
end
|
||||
end
|
||||
|
||||
local Context = Roact.createContext(nil)
|
||||
|
||||
local StudioProvider = Roact.Component:extend("StudioProvider")
|
||||
|
||||
function StudioProvider:init()
|
||||
self.settings = Settings.fromPlugin(self.props.plugin)
|
||||
end
|
||||
|
||||
function StudioProvider:render()
|
||||
return Roact.createElement(Context.Provider, {
|
||||
value = self.settings,
|
||||
}, self.props[Roact.Children])
|
||||
end
|
||||
|
||||
local InternalConsumer = Roact.Component:extend("InternalConsumer")
|
||||
|
||||
function InternalConsumer:render()
|
||||
return self.props.render(self.props.settings)
|
||||
end
|
||||
|
||||
function InternalConsumer:didMount()
|
||||
self.disconnect = self.props.settings:onUpdate(function()
|
||||
-- Trigger a dummy state update to update the settings consumer.
|
||||
self:setState({})
|
||||
end)
|
||||
end
|
||||
|
||||
function InternalConsumer:willUnmount()
|
||||
self.disconnect()
|
||||
end
|
||||
|
||||
local function with(callback)
|
||||
return Roact.createElement(Context.Consumer, {
|
||||
render = function(settings)
|
||||
return Roact.createElement(InternalConsumer, {
|
||||
settings = settings,
|
||||
render = callback,
|
||||
})
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
return {
|
||||
StudioProvider = StudioProvider,
|
||||
with = with,
|
||||
}
|
||||
@@ -6,7 +6,7 @@ local Roact = require(Rojo.Roact)
|
||||
local Config = require(Plugin.Config)
|
||||
local Version = require(Plugin.Version)
|
||||
local Assets = require(Plugin.Assets)
|
||||
local Theme = require(Plugin.Theme)
|
||||
local Theme = require(Plugin.Components.Theme)
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
@@ -18,50 +18,53 @@ function RojoFooter:init()
|
||||
end
|
||||
|
||||
function RojoFooter:render()
|
||||
return e("Frame", {
|
||||
LayoutOrder = 3,
|
||||
Size = UDim2.new(1, 0, 0, 32),
|
||||
BackgroundColor3 = Theme.SecondaryColor,
|
||||
BorderSizePixel = 0,
|
||||
}, {
|
||||
Padding = e("UIPadding", {
|
||||
PaddingTop = UDim.new(0, 4),
|
||||
PaddingBottom = UDim.new(0, 4),
|
||||
PaddingLeft = UDim.new(0, 8),
|
||||
PaddingRight = UDim.new(0, 8),
|
||||
}),
|
||||
|
||||
LogoContainer = e("Frame", {
|
||||
BackgroundTransparency = 1,
|
||||
|
||||
Size = UDim2.new(0, 0, 0, 32),
|
||||
return Theme.with(function(theme)
|
||||
return e("Frame", {
|
||||
LayoutOrder = 3,
|
||||
Size = UDim2.new(1, 0, 0, 32),
|
||||
BackgroundColor3 = theme.Background2,
|
||||
BorderSizePixel = 0,
|
||||
ZIndex = 2,
|
||||
}, {
|
||||
Logo = e("ImageLabel", {
|
||||
Image = Assets.Images.Logo,
|
||||
Size = UDim2.new(0, 80, 0, 40),
|
||||
ScaleType = Enum.ScaleType.Fit,
|
||||
BackgroundTransparency = 1,
|
||||
Position = UDim2.new(0, 0, 1, -10),
|
||||
AnchorPoint = Vector2.new(0, 1),
|
||||
Padding = e("UIPadding", {
|
||||
PaddingTop = UDim.new(0, 4),
|
||||
PaddingBottom = UDim.new(0, 4),
|
||||
PaddingLeft = UDim.new(0, 8),
|
||||
PaddingRight = UDim.new(0, 8),
|
||||
}),
|
||||
}),
|
||||
|
||||
Version = e("TextLabel", {
|
||||
Position = UDim2.new(1, 0, 0, 0),
|
||||
Size = UDim2.new(0, 0, 1, 0),
|
||||
AnchorPoint = Vector2.new(1, 0),
|
||||
Font = Theme.TitleFont,
|
||||
TextSize = 18,
|
||||
Text = Version.display(Config.version),
|
||||
TextXAlignment = Enum.TextXAlignment.Right,
|
||||
TextColor3 = Theme.LightTextColor,
|
||||
BackgroundTransparency = 1,
|
||||
LogoContainer = e("Frame", {
|
||||
BackgroundTransparency = 1,
|
||||
|
||||
[Roact.Change.AbsoluteSize] = function(rbx)
|
||||
self.setFooterVersionSize(rbx.AbsoluteSize)
|
||||
end,
|
||||
}),
|
||||
})
|
||||
Size = UDim2.new(0, 0, 0, 32),
|
||||
}, {
|
||||
Logo = e("ImageLabel", {
|
||||
Image = Assets.Images.Logo,
|
||||
Size = UDim2.new(0, 80, 0, 40),
|
||||
ScaleType = Enum.ScaleType.Fit,
|
||||
BackgroundTransparency = 1,
|
||||
Position = UDim2.new(0, 0, 1, -10),
|
||||
AnchorPoint = Vector2.new(0, 1),
|
||||
}),
|
||||
}),
|
||||
|
||||
Version = e("TextLabel", {
|
||||
Position = UDim2.new(1, 0, 0, 0),
|
||||
Size = UDim2.new(0, 0, 1, 0),
|
||||
AnchorPoint = Vector2.new(1, 0),
|
||||
Font = theme.TitleFont,
|
||||
TextSize = 18,
|
||||
Text = Version.display(Config.version),
|
||||
TextXAlignment = Enum.TextXAlignment.Right,
|
||||
TextColor3 = theme.Text2,
|
||||
BackgroundTransparency = 1,
|
||||
|
||||
[Roact.Change.AbsoluteSize] = function(rbx)
|
||||
self.setFooterVersionSize(rbx.AbsoluteSize)
|
||||
end,
|
||||
}),
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
return RojoFooter
|
||||
119
plugin/src/Components/SettingsPanel.lua
Normal file
119
plugin/src/Components/SettingsPanel.lua
Normal file
@@ -0,0 +1,119 @@
|
||||
local Roact = require(script:FindFirstAncestor("Rojo").Roact)
|
||||
|
||||
local Plugin = script:FindFirstAncestor("Plugin")
|
||||
|
||||
local Checkbox = require(Plugin.Components.Checkbox)
|
||||
local FitList = require(Plugin.Components.FitList)
|
||||
local FitText = require(Plugin.Components.FitText)
|
||||
local FormButton = require(Plugin.Components.FormButton)
|
||||
local Panel = require(Plugin.Components.Panel)
|
||||
local PluginSettings = require(Plugin.Components.PluginSettings)
|
||||
local Theme = require(Plugin.Components.Theme)
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
local SettingsPanel = Roact.Component:extend("SettingsPanel")
|
||||
|
||||
function SettingsPanel:render()
|
||||
local back = self.props.back
|
||||
|
||||
return Theme.with(function(theme)
|
||||
return PluginSettings.with(function(settings)
|
||||
return e(Panel, nil, {
|
||||
Layout = Roact.createElement("UIListLayout", {
|
||||
HorizontalAlignment = Enum.HorizontalAlignment.Center,
|
||||
VerticalAlignment = Enum.VerticalAlignment.Center,
|
||||
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||
Padding = UDim.new(0, 16),
|
||||
}),
|
||||
|
||||
OpenScriptsExternally = e(FitList, {
|
||||
containerProps = {
|
||||
LayoutOrder = 1,
|
||||
BackgroundTransparency = 1,
|
||||
},
|
||||
layoutProps = {
|
||||
Padding = UDim.new(0, 4),
|
||||
FillDirection = Enum.FillDirection.Horizontal,
|
||||
HorizontalAlignment = Enum.HorizontalAlignment.Left,
|
||||
VerticalAlignment = Enum.VerticalAlignment.Center,
|
||||
},
|
||||
}, {
|
||||
Label = e(FitText, {
|
||||
Kind = "TextLabel",
|
||||
LayoutOrder = 1,
|
||||
BackgroundTransparency = 1,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
Font = theme.MainFont,
|
||||
TextSize = 16,
|
||||
Text = "Open Scripts Externally",
|
||||
TextColor3 = theme.Text1,
|
||||
}),
|
||||
|
||||
Padding = e("Frame", {
|
||||
Size = UDim2.new(0, 8, 0, 0),
|
||||
BackgroundTransparency = 1,
|
||||
LayoutOrder = 2,
|
||||
}),
|
||||
|
||||
Input = e(Checkbox, {
|
||||
layoutOrder = 3,
|
||||
checked = settings:get("openScriptsExternally"),
|
||||
onChange = function(newValue)
|
||||
settings:set("openScriptsExternally", not settings:get("openScriptsExternally"))
|
||||
end,
|
||||
}),
|
||||
}),
|
||||
|
||||
TwoWaySync = e(FitList, {
|
||||
containerProps = {
|
||||
LayoutOrder = 2,
|
||||
BackgroundTransparency = 1,
|
||||
},
|
||||
layoutProps = {
|
||||
Padding = UDim.new(0, 4),
|
||||
FillDirection = Enum.FillDirection.Horizontal,
|
||||
HorizontalAlignment = Enum.HorizontalAlignment.Left,
|
||||
VerticalAlignment = Enum.VerticalAlignment.Center,
|
||||
},
|
||||
}, {
|
||||
Label = e(FitText, {
|
||||
Kind = "TextLabel",
|
||||
LayoutOrder = 1,
|
||||
BackgroundTransparency = 1,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
Font = theme.MainFont,
|
||||
TextSize = 16,
|
||||
Text = "Two-Way Sync (Experimental!)",
|
||||
TextColor3 = theme.Text1,
|
||||
}),
|
||||
|
||||
Padding = e("Frame", {
|
||||
Size = UDim2.new(0, 8, 0, 0),
|
||||
BackgroundTransparency = 1,
|
||||
LayoutOrder = 2,
|
||||
}),
|
||||
|
||||
Input = e(Checkbox, {
|
||||
layoutOrder = 3,
|
||||
checked = settings:get("twoWaySync"),
|
||||
onChange = function(newValue)
|
||||
settings:set("twoWaySync", not settings:get("twoWaySync"))
|
||||
end,
|
||||
}),
|
||||
}),
|
||||
|
||||
BackButton = e(FormButton, {
|
||||
layoutOrder = 4,
|
||||
text = "Okay",
|
||||
secondary = true,
|
||||
onClick = function()
|
||||
back()
|
||||
end,
|
||||
}),
|
||||
})
|
||||
end)
|
||||
end)
|
||||
end
|
||||
|
||||
return SettingsPanel
|
||||
113
plugin/src/Components/Theme.lua
Normal file
113
plugin/src/Components/Theme.lua
Normal file
@@ -0,0 +1,113 @@
|
||||
--[[
|
||||
Theming system taking advantage of Roact's new context API.
|
||||
|
||||
Doesn't use colors provided by Studio and instead just branches on theme
|
||||
name. This isn't exactly best practice.
|
||||
]]
|
||||
|
||||
-- Studio does not exist outside Roblox Studio, so we'll lazily initialize it
|
||||
-- when possible.
|
||||
local _Studio
|
||||
local function getStudio()
|
||||
if _Studio == nil then
|
||||
_Studio = settings():GetService("Studio")
|
||||
end
|
||||
|
||||
return _Studio
|
||||
end
|
||||
|
||||
local Rojo = script:FindFirstAncestor("Rojo")
|
||||
|
||||
local Roact = require(Rojo.Roact)
|
||||
local Log = require(Rojo.Log)
|
||||
|
||||
local strict = require(script.Parent.Parent.strict)
|
||||
|
||||
local lightTheme = strict("Theme", {
|
||||
ButtonFont = Enum.Font.GothamSemibold,
|
||||
InputFont = Enum.Font.Code,
|
||||
TitleFont = Enum.Font.GothamBold,
|
||||
MainFont = Enum.Font.Gotham,
|
||||
|
||||
Brand1 = Color3.fromRGB(225, 56, 53),
|
||||
|
||||
Text1 = Color3.fromRGB(64, 64, 64),
|
||||
Text2 = Color3.fromRGB(160, 160, 160),
|
||||
TextOnAccent = Color3.fromRGB(235, 235, 235),
|
||||
|
||||
Background1 = Color3.fromRGB(255, 255, 255),
|
||||
Background2 = Color3.fromRGB(235, 235, 235),
|
||||
})
|
||||
|
||||
local darkTheme = strict("Theme", {
|
||||
ButtonFont = Enum.Font.GothamSemibold,
|
||||
InputFont = Enum.Font.Code,
|
||||
TitleFont = Enum.Font.GothamBold,
|
||||
MainFont = Enum.Font.Gotham,
|
||||
|
||||
Brand1 = Color3.fromRGB(225, 56, 53),
|
||||
|
||||
Text1 = Color3.fromRGB(235, 235, 235),
|
||||
Text2 = Color3.fromRGB(200, 200, 200),
|
||||
TextOnAccent = Color3.fromRGB(235, 235, 235),
|
||||
|
||||
Background1 = Color3.fromRGB(48, 48, 48),
|
||||
Background2 = Color3.fromRGB(64, 64, 64),
|
||||
})
|
||||
|
||||
local Context = Roact.createContext(lightTheme)
|
||||
|
||||
local StudioProvider = Roact.Component:extend("StudioProvider")
|
||||
|
||||
-- Pull the current theme from Roblox Studio and update state with it.
|
||||
function StudioProvider:updateTheme()
|
||||
local studioTheme = getStudio().Theme
|
||||
|
||||
if studioTheme.Name == "Light" then
|
||||
self:setState({
|
||||
theme = lightTheme,
|
||||
})
|
||||
elseif studioTheme.Name == "Dark" then
|
||||
self:setState({
|
||||
theme = darkTheme,
|
||||
})
|
||||
else
|
||||
Log.warn("Unexpected theme '{}'' -- falling back to light theme!", studioTheme.Name)
|
||||
|
||||
self:setState({
|
||||
theme = lightTheme,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
function StudioProvider:init()
|
||||
self:updateTheme()
|
||||
end
|
||||
|
||||
function StudioProvider:render()
|
||||
return Roact.createElement(Context.Provider, {
|
||||
value = self.state.theme,
|
||||
}, self.props[Roact.Children])
|
||||
end
|
||||
|
||||
function StudioProvider:didMount()
|
||||
self.connection = getStudio().ThemeChanged:Connect(function()
|
||||
self:updateTheme()
|
||||
end)
|
||||
end
|
||||
|
||||
function StudioProvider:willUnmount()
|
||||
self.connection:Disconnect()
|
||||
end
|
||||
|
||||
local function with(callback)
|
||||
return Roact.createElement(Context.Consumer, {
|
||||
render = callback,
|
||||
})
|
||||
end
|
||||
|
||||
return {
|
||||
StudioProvider = StudioProvider,
|
||||
Consumer = Context.Consumer,
|
||||
with = with,
|
||||
}
|
||||
@@ -5,8 +5,8 @@ local isDevBuild = script.Parent.Parent:FindFirstChild("ROJO_DEV_BUILD") ~= nil
|
||||
return strict("Config", {
|
||||
isDevBuild = isDevBuild,
|
||||
codename = "Epiphany",
|
||||
version = {0, 6, 0, "-alpha.2"},
|
||||
expectedServerVersionString = "0.6.0 or newer",
|
||||
version = {6, 0, 0, "-rc.3"},
|
||||
expectedServerVersionString = "6.0 or newer",
|
||||
protocolVersion = 3,
|
||||
defaultHost = "localhost",
|
||||
defaultPort = 34872,
|
||||
|
||||
@@ -25,14 +25,6 @@ local VALUES = {
|
||||
[Environment.Test] = true,
|
||||
},
|
||||
},
|
||||
UnstableTwoWaySync = {
|
||||
type = "BoolValue",
|
||||
values = {
|
||||
[Environment.User] = false,
|
||||
[Environment.Dev] = false,
|
||||
[Environment.Test] = false,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
local CONTAINER_NAME = "RojoDevSettings" .. Config.codename
|
||||
@@ -140,10 +132,6 @@ function DevSettings:shouldTypecheck()
|
||||
return getValue("TypecheckingEnabled")
|
||||
end
|
||||
|
||||
function DevSettings:twoWaySyncEnabled()
|
||||
return getValue("UnstableTwoWaySync")
|
||||
end
|
||||
|
||||
function _G.ROJO_DEV_CREATE()
|
||||
DevSettings:createDevSettings()
|
||||
end
|
||||
|
||||
@@ -11,15 +11,38 @@ InstanceMap.__index = InstanceMap
|
||||
|
||||
function InstanceMap.new(onInstanceChanged)
|
||||
local self = {
|
||||
-- A map from IDs to instances.
|
||||
fromIds = {},
|
||||
|
||||
-- A map from instances to IDs.
|
||||
fromInstances = {},
|
||||
|
||||
-- A set of all instances that updates should be paused for. This set
|
||||
-- should generally be empty, and will be filled by pauseInstance
|
||||
-- temporarily.
|
||||
pausedUpdateInstances = {},
|
||||
|
||||
-- A map from instances to a signal or list of signals connected to it.
|
||||
instancesToSignal = {},
|
||||
|
||||
-- Callback that's invoked whenever an instance is changed and it was
|
||||
-- not paused.
|
||||
onInstanceChanged = onInstanceChanged,
|
||||
}
|
||||
|
||||
return setmetatable(self, InstanceMap)
|
||||
end
|
||||
|
||||
function InstanceMap:size()
|
||||
local size = 0
|
||||
|
||||
for _ in pairs(self.fromIds) do
|
||||
size = size + 1
|
||||
end
|
||||
|
||||
return size
|
||||
end
|
||||
|
||||
--[[
|
||||
Disconnect all connections and release all instance references.
|
||||
]]
|
||||
@@ -56,6 +79,9 @@ function InstanceMap:__fmtDebug(output)
|
||||
end
|
||||
|
||||
function InstanceMap:insert(id, instance)
|
||||
self:removeId(id)
|
||||
self:removeInstance(instance)
|
||||
|
||||
self.fromIds[id] = instance
|
||||
self.fromInstances[instance] = id
|
||||
self:__connectSignals(instance)
|
||||
@@ -68,8 +94,6 @@ function InstanceMap:removeId(id)
|
||||
self:__disconnectSignals(instance)
|
||||
self.fromIds[id] = nil
|
||||
self.fromInstances[instance] = nil
|
||||
else
|
||||
Log.warn("Attempted to remove nonexistant ID {}", id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -80,8 +104,6 @@ function InstanceMap:removeInstance(instance)
|
||||
if id ~= nil then
|
||||
self.fromInstances[instance] = nil
|
||||
self.fromIds[id] = nil
|
||||
else
|
||||
Log.warn("Attempted to remove nonexistant instance {}", instance)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -89,10 +111,14 @@ function InstanceMap:destroyInstance(instance)
|
||||
local id = self.fromInstances[instance]
|
||||
|
||||
if id ~= nil then
|
||||
self:destroyId(id)
|
||||
else
|
||||
Log.warn("Attempted to destroy untracked instance {}", instance)
|
||||
self:removeId(id)
|
||||
end
|
||||
|
||||
for _, descendantInstance in ipairs(instance:GetDescendants()) do
|
||||
self:removeInstance(descendantInstance)
|
||||
end
|
||||
|
||||
instance:Destroy()
|
||||
end
|
||||
|
||||
function InstanceMap:destroyId(id)
|
||||
@@ -100,21 +126,37 @@ function InstanceMap:destroyId(id)
|
||||
self:removeId(id)
|
||||
|
||||
if instance ~= nil then
|
||||
local descendantsToDestroy = {}
|
||||
|
||||
for otherInstance in pairs(self.fromInstances) do
|
||||
if otherInstance:IsDescendantOf(instance) then
|
||||
table.insert(descendantsToDestroy, otherInstance)
|
||||
end
|
||||
end
|
||||
|
||||
for _, otherInstance in ipairs(descendantsToDestroy) do
|
||||
self:removeInstance(otherInstance)
|
||||
for _, descendantInstance in ipairs(instance:GetDescendants()) do
|
||||
self:removeInstance(descendantInstance)
|
||||
end
|
||||
|
||||
instance:Destroy()
|
||||
end
|
||||
end
|
||||
|
||||
--[[
|
||||
Pause updates for an instance momentarily and invoke a callback.
|
||||
|
||||
If the callback throws an error, InstanceMap will still be kept in a
|
||||
consistent state.
|
||||
]]
|
||||
function InstanceMap:pauseInstance(instance, callback)
|
||||
local id = self.fromInstances[instance]
|
||||
|
||||
-- If we don't know about this instance, ignore it and do not invoke the
|
||||
-- callback.
|
||||
if id == nil then
|
||||
return
|
||||
end
|
||||
|
||||
self.pausedUpdateInstances[instance] = true
|
||||
local success, result = xpcall(callback, debug.traceback)
|
||||
self.pausedUpdateInstances[instance] = false
|
||||
|
||||
if success then
|
||||
return result
|
||||
else
|
||||
Log.warn("Attempted to destroy nonexistant ID {}", id)
|
||||
error(result, 2)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -150,9 +192,15 @@ end
|
||||
function InstanceMap:__maybeFireInstanceChanged(instance, propertyName)
|
||||
Log.trace("{}.{} changed", instance:GetFullName(), propertyName)
|
||||
|
||||
if self.onInstanceChanged ~= nil then
|
||||
self.onInstanceChanged(instance, propertyName)
|
||||
if self.pausedUpdateInstances[instance] then
|
||||
return
|
||||
end
|
||||
|
||||
if self.onInstanceChanged == nil then
|
||||
return
|
||||
end
|
||||
|
||||
self.onInstanceChanged(instance, propertyName)
|
||||
end
|
||||
|
||||
function InstanceMap:__disconnectSignals(instance)
|
||||
|
||||
184
plugin/src/PatchSet.lua
Normal file
184
plugin/src/PatchSet.lua
Normal file
@@ -0,0 +1,184 @@
|
||||
--[[
|
||||
Methods to operate on either a patch created by the hydrate method, or a
|
||||
patch returned from the API.
|
||||
]]
|
||||
|
||||
local t = require(script.Parent.Parent.t)
|
||||
|
||||
local Types = require(script.Parent.Types)
|
||||
|
||||
local PatchSet = {}
|
||||
|
||||
PatchSet.validate = t.interface({
|
||||
removed = t.array(t.union(Types.RbxId, t.Instance)),
|
||||
added = t.map(Types.RbxId, Types.ApiInstance),
|
||||
updated = t.array(Types.ApiInstanceUpdate),
|
||||
})
|
||||
|
||||
--[[
|
||||
Create a new, empty PatchSet.
|
||||
]]
|
||||
function PatchSet.newEmpty()
|
||||
return {
|
||||
removed = {},
|
||||
added = {},
|
||||
updated = {},
|
||||
}
|
||||
end
|
||||
|
||||
--[[
|
||||
Tells whether the given PatchSet is empty.
|
||||
]]
|
||||
function PatchSet.isEmpty(patchSet)
|
||||
return next(patchSet.removed) == nil and
|
||||
next(patchSet.added) == nil and
|
||||
next(patchSet.updated) == nil
|
||||
end
|
||||
|
||||
--[[
|
||||
Tells whether the given PatchSet has any remove operations.
|
||||
]]
|
||||
function PatchSet.hasRemoves(patchSet)
|
||||
return next(patchSet.removed) ~= nil
|
||||
end
|
||||
|
||||
--[[
|
||||
Tells whether the given PatchSet has any add operations.
|
||||
]]
|
||||
function PatchSet.hasAdditions(patchSet)
|
||||
return next(patchSet.added) ~= nil
|
||||
end
|
||||
|
||||
--[[
|
||||
Tells whether the given PatchSet has any update operations.
|
||||
]]
|
||||
function PatchSet.hasUpdates(patchSet)
|
||||
return next(patchSet.updated) ~= nil
|
||||
end
|
||||
|
||||
--[[
|
||||
Merge multiple PatchSet objects into the given PatchSet.
|
||||
]]
|
||||
function PatchSet.assign(target, ...)
|
||||
for i = 1, select("#", ...) do
|
||||
local sourcePatch = select(i, ...)
|
||||
|
||||
for _, removed in ipairs(sourcePatch.removed) do
|
||||
table.insert(target.removed, removed)
|
||||
end
|
||||
|
||||
for id, added in pairs(sourcePatch.added) do
|
||||
target.added[id] = added
|
||||
end
|
||||
|
||||
for _, update in ipairs(sourcePatch.updated) do
|
||||
table.insert(target.updated, update)
|
||||
end
|
||||
end
|
||||
|
||||
return target
|
||||
end
|
||||
|
||||
--[[
|
||||
Create a list of human-readable statements summarizing the contents of this
|
||||
patch, intended to be displayed to users.
|
||||
]]
|
||||
function PatchSet.humanSummary(instanceMap, patchSet)
|
||||
local statements = {}
|
||||
|
||||
for _, idOrInstance in ipairs(patchSet.removed) do
|
||||
local instance, id
|
||||
|
||||
if type(idOrInstance) == "string" then
|
||||
id = idOrInstance
|
||||
instance = instanceMap.fromIds[id]
|
||||
else
|
||||
instance = idOrInstance
|
||||
id = instanceMap.fromInstances[instance]
|
||||
end
|
||||
|
||||
if instance ~= nil then
|
||||
table.insert(statements, string.format("- Delete instance %s", instance:GetFullName()))
|
||||
else
|
||||
table.insert(statements, string.format("- Delete instance with ID %s", id))
|
||||
end
|
||||
end
|
||||
|
||||
local additionsMentioned = {}
|
||||
|
||||
local function addAllDescendents(virtualInstance)
|
||||
additionsMentioned[virtualInstance.Id] = true
|
||||
|
||||
for _, childId in ipairs(virtualInstance.Children) do
|
||||
addAllDescendents(patchSet.added[childId])
|
||||
end
|
||||
end
|
||||
|
||||
for id, addition in pairs(patchSet.added) do
|
||||
if additionsMentioned[id] then
|
||||
continue
|
||||
end
|
||||
|
||||
local virtualInstance = addition
|
||||
while true do
|
||||
if virtualInstance.Parent == nil then
|
||||
break
|
||||
end
|
||||
|
||||
local virtualParent = patchSet.added[virtualInstance.Parent]
|
||||
if virtualParent == nil then
|
||||
break
|
||||
end
|
||||
|
||||
virtualInstance = virtualParent
|
||||
end
|
||||
|
||||
local parentDisplayName = "nil (how strange!)"
|
||||
if virtualInstance.Parent ~= nil then
|
||||
local parent = instanceMap.fromIds[virtualInstance.Parent]
|
||||
if parent ~= nil then
|
||||
parentDisplayName = parent:GetFullName()
|
||||
end
|
||||
end
|
||||
|
||||
table.insert(statements, string.format(
|
||||
"- Add instance %q (ClassName %q) to %s",
|
||||
virtualInstance.Name, virtualInstance.ClassName, parentDisplayName))
|
||||
end
|
||||
|
||||
for _, update in ipairs(patchSet.updated) do
|
||||
local updatedProperties = {}
|
||||
|
||||
if update.changedMetadata ~= nil then
|
||||
table.insert(updatedProperties, "Rojo's Metadata")
|
||||
end
|
||||
|
||||
if update.changedName ~= nil then
|
||||
table.insert(updatedProperties, "Name")
|
||||
end
|
||||
|
||||
if update.changedClassName ~= nil then
|
||||
table.insert(updatedProperties, "ClassName")
|
||||
end
|
||||
|
||||
for name in pairs(update.changedProperties) do
|
||||
table.insert(updatedProperties, name)
|
||||
end
|
||||
|
||||
local instance = instanceMap.fromIds[update.id]
|
||||
local displayName
|
||||
if instance ~= nil then
|
||||
displayName = instance:GetFullName()
|
||||
else
|
||||
displayName = "[unknown instance]"
|
||||
end
|
||||
|
||||
table.insert(statements, string.format(
|
||||
"- Update properties on %s: %s",
|
||||
displayName, table.concat(updatedProperties, ",")))
|
||||
end
|
||||
|
||||
return table.concat(statements, "\n")
|
||||
end
|
||||
|
||||
return PatchSet
|
||||
@@ -1,401 +0,0 @@
|
||||
--[[
|
||||
This module defines the meat of the Rojo plugin and how it manages tracking
|
||||
and mutating the Roblox DOM.
|
||||
]]
|
||||
|
||||
local RbxDom = require(script.Parent.Parent.RbxDom)
|
||||
local t = require(script.Parent.Parent.t)
|
||||
local Log = require(script.Parent.Parent.Log)
|
||||
|
||||
local Types = require(script.Parent.Types)
|
||||
local invariant = require(script.Parent.invariant)
|
||||
local getCanonicalProperty = require(script.Parent.getCanonicalProperty)
|
||||
local setCanonicalProperty = require(script.Parent.setCanonicalProperty)
|
||||
|
||||
--[[
|
||||
This interface represents either a patch created by the hydrate method, or a
|
||||
patch returned from the API.
|
||||
|
||||
This type should be a subset of Types.ApiInstanceUpdate.
|
||||
]]
|
||||
local IPatch = t.interface({
|
||||
removed = t.array(t.union(Types.RbxId, t.Instance)),
|
||||
added = t.map(Types.RbxId, Types.ApiInstance),
|
||||
updated = t.array(Types.ApiInstanceUpdate),
|
||||
})
|
||||
|
||||
--[[
|
||||
Attempt to safely set the parent of an instance.
|
||||
|
||||
This function will always succeed, even if the actual set failed. This is
|
||||
important for some types like services that will throw even if their current
|
||||
parent is already set to the requested parent.
|
||||
|
||||
TODO: See if we can eliminate this by being more nuanced with property
|
||||
assignment?
|
||||
]]
|
||||
local function safeSetParent(instance, newParent)
|
||||
pcall(function()
|
||||
instance.Parent = newParent
|
||||
end)
|
||||
end
|
||||
|
||||
--[[
|
||||
Similar to setting Parent, some instances really don't like being renamed.
|
||||
|
||||
TODO: Should we be throwing away these results or can we be more careful?
|
||||
]]
|
||||
local function safeSetName(instance, name)
|
||||
pcall(function()
|
||||
instance.Name = name
|
||||
end)
|
||||
end
|
||||
|
||||
local Reconciler = {}
|
||||
Reconciler.__index = Reconciler
|
||||
|
||||
function Reconciler.new(instanceMap)
|
||||
local self = {
|
||||
-- Tracks all of the instances known by the reconciler by ID.
|
||||
__instanceMap = instanceMap,
|
||||
}
|
||||
|
||||
return setmetatable(self, Reconciler)
|
||||
end
|
||||
|
||||
--[[
|
||||
See Reconciler:__hydrateInternal().
|
||||
]]
|
||||
function Reconciler:hydrate(apiInstances, id, instance)
|
||||
local hydratePatch = {
|
||||
removed = {},
|
||||
added = {},
|
||||
updated = {},
|
||||
}
|
||||
|
||||
self:__hydrateInternal(apiInstances, id, instance, hydratePatch)
|
||||
|
||||
return hydratePatch
|
||||
end
|
||||
|
||||
--[[
|
||||
Applies a patch to the Roblox DOM using the reconciler's internal state.
|
||||
|
||||
TODO: This function might only apply some of the patch in the future and
|
||||
require content negotiation with the Rojo server to handle types that aren't
|
||||
editable by scripts.
|
||||
]]
|
||||
local applyPatchSchema = Types.ifEnabled(t.tuple(
|
||||
IPatch
|
||||
))
|
||||
function Reconciler:applyPatch(patch)
|
||||
assert(applyPatchSchema(patch))
|
||||
|
||||
for _, removedIdOrInstance in ipairs(patch.removed) do
|
||||
local removedInstance
|
||||
|
||||
if Types.RbxId(removedIdOrInstance) then
|
||||
-- If this value is an ID, it's assumed to be an instance that the
|
||||
-- Rojo server knows about.
|
||||
removedInstance = self.__instanceMap.fromIds[removedIdOrInstance]
|
||||
self.__instanceMap:removeId(removedIdOrInstance)
|
||||
end
|
||||
|
||||
-- If this entry was an ID that we didn't know about, removedInstance
|
||||
-- will be nil, which we guard against in case of minor tree desync.
|
||||
if removedInstance ~= nil then
|
||||
-- Ensure that if any descendants are tracked by Rojo, that we
|
||||
-- properly un-track them.
|
||||
for _, descendantInstance in ipairs(removedInstance:GetDescendants()) do
|
||||
self.__instanceMap:removeInstance(descendantInstance)
|
||||
end
|
||||
|
||||
removedInstance:Destroy()
|
||||
end
|
||||
end
|
||||
|
||||
-- TODO: This loop assumes that apiInstance.ParentId is never nil. The Rojo
|
||||
-- plugin can't create a new top-level DataModel anyways, so this should
|
||||
-- only be violated in cases that are already erroneous.
|
||||
for id, apiInstance in pairs(patch.added) do
|
||||
if self.__instanceMap.fromIds[id] == nil then
|
||||
-- Find the first ancestor of this instance that is marked for an
|
||||
-- addition.
|
||||
--
|
||||
-- This helps us make sure we only reify each instance once, and we
|
||||
-- start from the top.
|
||||
while patch.added[apiInstance.Parent] ~= nil do
|
||||
id = apiInstance.Parent
|
||||
apiInstance = patch.added[id]
|
||||
end
|
||||
|
||||
local parentInstance = self.__instanceMap.fromIds[apiInstance.Parent]
|
||||
|
||||
if parentInstance == nil then
|
||||
invariant(
|
||||
"Cannot add an instance from a patch that has no parent.\nInstance {} with parent {}.\nState: {:#?}",
|
||||
id,
|
||||
apiInstance.Parent,
|
||||
self.__instanceMap
|
||||
)
|
||||
end
|
||||
|
||||
self:__reifyInstance(patch.added, id, parentInstance)
|
||||
end
|
||||
end
|
||||
|
||||
for _, update in ipairs(patch.updated) do
|
||||
local instance = self.__instanceMap.fromIds[update.id]
|
||||
|
||||
if instance == nil then
|
||||
invariant(
|
||||
"Cannot update an instance that does not exist in the reconciler's state.\nInstance {}\nState: {:#?}",
|
||||
update.id,
|
||||
self.__instanceMap
|
||||
)
|
||||
end
|
||||
|
||||
if update.changedClassName ~= nil then
|
||||
error("TODO: Support changing class name by destroying + recreating instance.")
|
||||
end
|
||||
|
||||
if update.changedName ~= nil then
|
||||
instance.Name = update.changedName
|
||||
end
|
||||
|
||||
if update.changedMetadata ~= nil then
|
||||
print("TODO: Support changing metadata, if necessary.")
|
||||
end
|
||||
|
||||
if update.changedProperties ~= nil then
|
||||
for propertyName, propertyValue in pairs(update.changedProperties) do
|
||||
-- TODO: Gracefully handle this error instead?
|
||||
assert(setCanonicalProperty(instance, propertyName, self:__decodeApiValue(propertyValue)))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
--[[
|
||||
Transforms a value into one that can be sent over the network back to the
|
||||
Rojo server.
|
||||
|
||||
This operation can fail, and so it returns bool, value.
|
||||
]]
|
||||
function Reconciler:encodeApiValue(value)
|
||||
if typeof(value) == "string" then
|
||||
return true, {
|
||||
Type = "String",
|
||||
Value = value,
|
||||
}
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
--[[
|
||||
Transforms a value encoded by rbx_dom_weak on the server side into a value
|
||||
usable by Rojo's reconciler, potentially using RbxDom.
|
||||
]]
|
||||
function Reconciler:__decodeApiValue(apiValue)
|
||||
assert(Types.ApiValue(apiValue))
|
||||
|
||||
-- Refs are represented as IDs in the same space that Rojo's protocol uses.
|
||||
if apiValue.Type == "Ref" then
|
||||
-- TODO: This ref could be pointing at an instance we haven't created
|
||||
-- yet!
|
||||
|
||||
return self.__instanceMap.fromIds[apiValue.Value]
|
||||
end
|
||||
|
||||
local success, decodedValue = RbxDom.EncodedValue.decode(apiValue)
|
||||
|
||||
if not success then
|
||||
error(decodedValue, 2)
|
||||
end
|
||||
|
||||
return decodedValue
|
||||
end
|
||||
|
||||
--[[
|
||||
Constructs an instance from an ApiInstance without any of its children.
|
||||
]]
|
||||
local reifySingleInstanceSchema = Types.ifEnabled(t.tuple(
|
||||
Types.ApiInstance
|
||||
))
|
||||
function Reconciler:__reifySingleInstance(apiInstance)
|
||||
assert(reifySingleInstanceSchema(apiInstance))
|
||||
|
||||
-- Instance.new can fail if we're passing in something that can't be
|
||||
-- created, like a service, something enabled with a feature flag, or
|
||||
-- something that requires higher security than we have.
|
||||
local ok, instance = pcall(Instance.new, apiInstance.ClassName)
|
||||
if not ok then
|
||||
return false, instance
|
||||
end
|
||||
|
||||
-- TODO: When can setting Name fail here?
|
||||
safeSetName(instance, apiInstance.Name)
|
||||
|
||||
for key, value in pairs(apiInstance.Properties) do
|
||||
setCanonicalProperty(instance, key, self:__decodeApiValue(value))
|
||||
end
|
||||
|
||||
return true, instance
|
||||
end
|
||||
|
||||
--[[
|
||||
Construct an instance and all of its descendants, parent it to the given
|
||||
instance, and insert it into the reconciler's internal state.
|
||||
]]
|
||||
local reifyInstanceSchema = Types.ifEnabled(t.tuple(
|
||||
t.map(Types.RbxId, Types.VirtualInstance),
|
||||
Types.RbxId,
|
||||
t.Instance
|
||||
))
|
||||
function Reconciler:__reifyInstance(apiInstances, id, parentInstance)
|
||||
assert(reifyInstanceSchema(apiInstances, id, parentInstance))
|
||||
|
||||
local apiInstance = apiInstances[id]
|
||||
local ok, instance = self:__reifySingleInstance(apiInstance)
|
||||
|
||||
-- TODO: Propagate this error upward to handle it elsewhere?
|
||||
if not ok then
|
||||
error(("Couldn't create an instance of type %q, a child of %s"):format(
|
||||
apiInstance.ClassName,
|
||||
parentInstance:GetFullName()
|
||||
))
|
||||
end
|
||||
|
||||
self.__instanceMap:insert(id, instance)
|
||||
|
||||
for _, childId in ipairs(apiInstance.Children) do
|
||||
self:__reifyInstance(apiInstances, childId, instance)
|
||||
end
|
||||
|
||||
safeSetParent(instance, parentInstance)
|
||||
|
||||
return instance
|
||||
end
|
||||
|
||||
--[[
|
||||
Populates the reconciler's internal state, maps IDs to instances that the
|
||||
Rojo plugin knows about, and generates a patch that would update the Roblox
|
||||
tree to match Rojo's view of the tree.
|
||||
]]
|
||||
local hydrateSchema = Types.ifEnabled(t.tuple(
|
||||
t.map(Types.RbxId, Types.VirtualInstance),
|
||||
Types.RbxId,
|
||||
t.Instance,
|
||||
IPatch
|
||||
))
|
||||
function Reconciler:__hydrateInternal(apiInstances, id, instance, hydratePatch)
|
||||
assert(hydrateSchema(apiInstances, id, instance, hydratePatch))
|
||||
|
||||
self.__instanceMap:insert(id, instance)
|
||||
|
||||
local apiInstance = apiInstances[id]
|
||||
|
||||
local function markIdAdded(id)
|
||||
local apiInstance = apiInstances[id]
|
||||
hydratePatch.added[id] = apiInstance
|
||||
|
||||
for _, childId in ipairs(apiInstance.Children) do
|
||||
markIdAdded(childId)
|
||||
end
|
||||
end
|
||||
|
||||
local changedName = nil
|
||||
local changedProperties = {}
|
||||
|
||||
if apiInstance.Name ~= instance.Name then
|
||||
changedName = apiInstance.Name
|
||||
end
|
||||
|
||||
for propertyName, virtualValue in pairs(apiInstance.Properties) do
|
||||
local success, existingValue = getCanonicalProperty(instance, propertyName)
|
||||
|
||||
if success then
|
||||
local decodedValue = self:__decodeApiValue(virtualValue)
|
||||
|
||||
if existingValue ~= decodedValue then
|
||||
changedProperties[propertyName] = virtualValue
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- If any properties differed from the virtual instance we read, add it to
|
||||
-- the hydrate patch so that we can catch up.
|
||||
if changedName ~= nil or next(changedProperties) ~= nil then
|
||||
table.insert(hydratePatch.updated, {
|
||||
id = id,
|
||||
changedName = changedName,
|
||||
changedClassName = nil,
|
||||
changedProperties = changedProperties,
|
||||
changedMetadata = nil,
|
||||
})
|
||||
end
|
||||
|
||||
local existingChildren = instance:GetChildren()
|
||||
|
||||
-- For each existing child, we'll track whether it's been paired with an
|
||||
-- instance that the Rojo server knows about.
|
||||
local isExistingChildVisited = {}
|
||||
for i = 1, #existingChildren do
|
||||
isExistingChildVisited[i] = false
|
||||
end
|
||||
|
||||
for _, childId in ipairs(apiInstance.Children) do
|
||||
local apiChild = apiInstances[childId]
|
||||
|
||||
local childInstance
|
||||
|
||||
for childIndex, instance in ipairs(existingChildren) do
|
||||
if not isExistingChildVisited[childIndex] then
|
||||
-- We guard accessing Name and ClassName in order to avoid
|
||||
-- tripping over children of DataModel that Rojo won't have
|
||||
-- permissions to access at all.
|
||||
local ok, name, className = pcall(function()
|
||||
return instance.Name, instance.ClassName
|
||||
end)
|
||||
|
||||
-- This rule is very conservative and could be loosened in the
|
||||
-- future, or more heuristics could be introduced.
|
||||
if ok and name == apiChild.Name and className == apiChild.ClassName then
|
||||
childInstance = instance
|
||||
isExistingChildVisited[childIndex] = true
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if childInstance ~= nil then
|
||||
-- We found an instance that matches the instance from the API, yay!
|
||||
self:__hydrateInternal(apiInstances, childId, childInstance, hydratePatch)
|
||||
else
|
||||
markIdAdded(childId)
|
||||
end
|
||||
end
|
||||
|
||||
-- Any unvisited children at this point aren't known by Rojo and we can
|
||||
-- destroy them unless the user has explicitly asked us to preserve children
|
||||
-- of this instance.
|
||||
local shouldClearUnknown = self:__shouldClearUnknownChildren(apiInstance)
|
||||
if shouldClearUnknown then
|
||||
for childIndex, visited in ipairs(isExistingChildVisited) do
|
||||
if not visited then
|
||||
table.insert(hydratePatch.removed, existingChildren[childIndex])
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Reconciler:__shouldClearUnknownChildren(apiInstance)
|
||||
if apiInstance.Metadata ~= nil then
|
||||
return not apiInstance.Metadata.ignoreUnknownInstances
|
||||
else
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
return Reconciler
|
||||
37
plugin/src/Reconciler/Error.lua
Normal file
37
plugin/src/Reconciler/Error.lua
Normal file
@@ -0,0 +1,37 @@
|
||||
--[[
|
||||
Defines the errors that can be returned by the reconciler.
|
||||
]]
|
||||
|
||||
local Fmt = require(script.Parent.Parent.Parent.Fmt)
|
||||
|
||||
local Error = {}
|
||||
|
||||
local function makeVariant(name)
|
||||
Error[name] = setmetatable({}, {
|
||||
__tostring = function()
|
||||
return "Error." .. name
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
makeVariant("CannotCreateInstance")
|
||||
makeVariant("CannotDecodeValue")
|
||||
makeVariant("LackingPropertyPermissions")
|
||||
makeVariant("OtherPropertyError")
|
||||
makeVariant("RefDidNotExist")
|
||||
makeVariant("UnknownProperty")
|
||||
makeVariant("UnreadableProperty")
|
||||
makeVariant("UnwritableProperty")
|
||||
|
||||
function Error.new(kind, details)
|
||||
return setmetatable({
|
||||
kind = kind,
|
||||
details = details,
|
||||
}, Error)
|
||||
end
|
||||
|
||||
function Error:__tostring()
|
||||
return Fmt.fmt("Error({}): {:#?}", self.kind, self.details)
|
||||
end
|
||||
|
||||
return Error
|
||||
200
plugin/src/Reconciler/applyPatch.lua
Normal file
200
plugin/src/Reconciler/applyPatch.lua
Normal file
@@ -0,0 +1,200 @@
|
||||
--[[
|
||||
Apply a patch to the DOM. Returns any portions of the patch that weren't
|
||||
possible to apply.
|
||||
|
||||
Patches can come from the server or be generated by the client.
|
||||
]]
|
||||
|
||||
local Log = require(script.Parent.Parent.Parent.Log)
|
||||
|
||||
local PatchSet = require(script.Parent.Parent.PatchSet)
|
||||
local Types = require(script.Parent.Parent.Types)
|
||||
local invariant = require(script.Parent.Parent.invariant)
|
||||
|
||||
local decodeValue = require(script.Parent.decodeValue)
|
||||
local reify = require(script.Parent.reify)
|
||||
local setProperty = require(script.Parent.setProperty)
|
||||
|
||||
local function applyPatch(instanceMap, patch)
|
||||
-- Tracks any portions of the patch that could not be applied to the DOM.
|
||||
local unappliedPatch = PatchSet.newEmpty()
|
||||
|
||||
for _, removedIdOrInstance in ipairs(patch.removed) do
|
||||
if Types.RbxId(removedIdOrInstance) then
|
||||
instanceMap:destroyId(removedIdOrInstance)
|
||||
else
|
||||
instanceMap:destroyInstance(removedIdOrInstance)
|
||||
end
|
||||
end
|
||||
|
||||
for id, virtualInstance in pairs(patch.added) do
|
||||
if instanceMap.fromIds[id] ~= nil then
|
||||
-- This instance already exists. We might've already added it in a
|
||||
-- previous iteration of this loop, or maybe this patch was not
|
||||
-- supposed to list this instance.
|
||||
--
|
||||
-- It's probably fine, right?
|
||||
continue
|
||||
end
|
||||
|
||||
-- Find the first ancestor of this instance that is marked for an
|
||||
-- addition.
|
||||
--
|
||||
-- This helps us make sure we only reify each instance once, and we
|
||||
-- start from the top.
|
||||
while patch.added[virtualInstance.Parent] ~= nil do
|
||||
id = virtualInstance.Parent
|
||||
virtualInstance = patch.added[id]
|
||||
end
|
||||
|
||||
local parentInstance = instanceMap.fromIds[virtualInstance.Parent]
|
||||
|
||||
if parentInstance == nil then
|
||||
-- This would be peculiar. If you create an instance with no
|
||||
-- parent, were you supposed to create it at all?
|
||||
invariant(
|
||||
"Cannot add an instance from a patch that has no parent.\nInstance {} with parent {}.\nState: {:#?}",
|
||||
id,
|
||||
virtualInstance.Parent,
|
||||
instanceMap
|
||||
)
|
||||
end
|
||||
|
||||
local failedToReify = reify(instanceMap, patch.added, id, parentInstance)
|
||||
|
||||
if not PatchSet.isEmpty(failedToReify) then
|
||||
Log.debug("Failed to reify as part of applying a patch: {}", failedToReify)
|
||||
PatchSet.assign(unappliedPatch, failedToReify)
|
||||
end
|
||||
end
|
||||
|
||||
for _, update in ipairs(patch.updated) do
|
||||
local instance = instanceMap.fromIds[update.id]
|
||||
|
||||
if instance == nil then
|
||||
-- We can't update an instance that doesn't exist.
|
||||
table.insert(unappliedPatch.updated, update)
|
||||
continue
|
||||
end
|
||||
|
||||
-- Track any part of this update that could not be applied.
|
||||
local unappliedUpdate = {
|
||||
id = update.id,
|
||||
changedProperties = {},
|
||||
}
|
||||
local partiallyApplied = false
|
||||
|
||||
-- If the instance's className changed, we have a bumpy ride ahead while
|
||||
-- we recreate this instance and move all of its children into the new
|
||||
-- version atomically...ish.
|
||||
if update.changedClassName ~= nil then
|
||||
-- If the instance's name also changed, we'll do it here, since this
|
||||
-- branch will skip the rest of the loop iteration.
|
||||
local newName = update.changedName or instance.Name
|
||||
|
||||
-- TODO: When changing between instances that have similar sets of
|
||||
-- properties, like between an ImageLabel and an ImageButton, we
|
||||
-- should preserve all of the properties that are shared between the
|
||||
-- two classes unless they're changed as part of this patch. This is
|
||||
-- similar to how "class changer" Studio plugins work.
|
||||
--
|
||||
-- For now, we'll only apply properties that are mentioned in this
|
||||
-- update. Patches with changedClassName set only occur in specific
|
||||
-- circumstances, usually between Folder and ModuleScript instances.
|
||||
-- While this may result in some issues, like not preserving the
|
||||
-- "Archived" property, a robust solution is sufficiently
|
||||
-- complicated that we're pushing it off for now.
|
||||
local newProperties = update.changedProperties
|
||||
|
||||
-- If the instance's ClassName changed, we'll kick into reify to
|
||||
-- create this instance. We'll handle moving all of children between
|
||||
-- the instances after the new one is created.
|
||||
local mockVirtualInstance = {
|
||||
Id = update.id,
|
||||
Name = newName,
|
||||
ClassName = update.changedClassName,
|
||||
Properties = newProperties,
|
||||
Children = {},
|
||||
}
|
||||
|
||||
local mockAdded = {
|
||||
[update.id] = mockVirtualInstance,
|
||||
}
|
||||
|
||||
local failedToReify = reify(instanceMap, mockAdded, update.id, instance.Parent)
|
||||
|
||||
local newInstance = instanceMap.fromIds[update.id]
|
||||
|
||||
-- Some parts of reify may have failed, but this is not necessarily
|
||||
-- critical. If the instance wasn't recreated or has the wrong Name,
|
||||
-- we'll consider our attempt a failure.
|
||||
if instance == newInstance or newInstance.Name ~= newName then
|
||||
table.insert(unappliedPatch.updated, update)
|
||||
continue
|
||||
end
|
||||
|
||||
-- Here are the non-critical failures. We know that the instance
|
||||
-- succeeded in creating and that assigning Name did not fail, but
|
||||
-- other property assignments might've failed.
|
||||
if not PatchSet.isEmpty(failedToReify) then
|
||||
PatchSet.assign(unappliedPatch, failedToReify)
|
||||
end
|
||||
|
||||
-- Watch out, this is the scary part! Move all of the children of
|
||||
-- instance into newInstance.
|
||||
--
|
||||
-- TODO: If this fails part way through, should we move everything
|
||||
-- back? For now, we assume that moving things will not fail.
|
||||
for _, child in ipairs(instance:GetChildren()) do
|
||||
child.Parent = newInstance
|
||||
end
|
||||
|
||||
-- See you later, original instance.
|
||||
--
|
||||
-- TODO: Can this fail? Some kinds of instance may not appreciate
|
||||
-- being destroyed, like services.
|
||||
instance:Destroy()
|
||||
|
||||
-- This completes your rebuilding a plane mid-flight safety
|
||||
-- instruction. Please sit back, relax, and enjoy your flight.
|
||||
continue
|
||||
end
|
||||
|
||||
if update.changedName ~= nil then
|
||||
instance.Name = update.changedName
|
||||
end
|
||||
|
||||
if update.changedMetadata ~= nil then
|
||||
-- TODO: Support changing metadata. This will become necessary when
|
||||
-- Rojo persistently tracks metadata for each instance in order to
|
||||
-- remove extra instances.
|
||||
unappliedUpdate.changedMetadata = update.changedMetadata
|
||||
partiallyApplied = true
|
||||
end
|
||||
|
||||
if update.changedProperties ~= nil then
|
||||
for propertyName, propertyValue in pairs(update.changedProperties) do
|
||||
local ok, decodedValue = decodeValue(propertyValue, instanceMap)
|
||||
if not ok then
|
||||
unappliedUpdate.changedProperties[propertyName] = propertyValue
|
||||
partiallyApplied = true
|
||||
continue
|
||||
end
|
||||
|
||||
local ok = setProperty(instance, propertyName, decodedValue)
|
||||
if not ok then
|
||||
unappliedUpdate.changedProperties[propertyName] = propertyValue
|
||||
partiallyApplied = true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if partiallyApplied then
|
||||
table.insert(unappliedPatch.updated, unappliedUpdate)
|
||||
end
|
||||
end
|
||||
|
||||
return unappliedPatch
|
||||
end
|
||||
|
||||
return applyPatch
|
||||
198
plugin/src/Reconciler/applyPatch.spec.lua
Normal file
198
plugin/src/Reconciler/applyPatch.spec.lua
Normal file
@@ -0,0 +1,198 @@
|
||||
return function()
|
||||
local applyPatch = require(script.Parent.applyPatch)
|
||||
|
||||
local InstanceMap = require(script.Parent.Parent.InstanceMap)
|
||||
local PatchSet = require(script.Parent.Parent.PatchSet)
|
||||
|
||||
local dummy = Instance.new("Folder")
|
||||
local function wasDestroyed(instance)
|
||||
-- If an instance was destroyed, its parent property is locked.
|
||||
local ok = pcall(function()
|
||||
local oldParent = instance.Parent
|
||||
instance.Parent = dummy
|
||||
instance.Parent = oldParent
|
||||
end)
|
||||
|
||||
return not ok
|
||||
end
|
||||
|
||||
it("should return an empty patch if given an empty patch", function()
|
||||
local patch = applyPatch(InstanceMap.new(), PatchSet.newEmpty())
|
||||
assert(PatchSet.isEmpty(patch), "expected remaining patch to be empty")
|
||||
end)
|
||||
|
||||
it("should destroy instances listed for remove", function()
|
||||
local root = Instance.new("Folder")
|
||||
|
||||
local child = Instance.new("Folder")
|
||||
child.Name = "Child"
|
||||
child.Parent = root
|
||||
|
||||
local instanceMap = InstanceMap.new()
|
||||
instanceMap:insert("ROOT", root)
|
||||
instanceMap:insert("CHILD", child)
|
||||
|
||||
local patch = PatchSet.newEmpty()
|
||||
table.insert(patch.removed, child)
|
||||
|
||||
local unapplied = applyPatch(instanceMap, patch)
|
||||
assert(PatchSet.isEmpty(unapplied), "expected remaining patch to be empty")
|
||||
|
||||
assert(not wasDestroyed(root), "expected root to be left alone")
|
||||
assert(wasDestroyed(child), "expected child to be destroyed")
|
||||
|
||||
instanceMap:stop()
|
||||
end)
|
||||
|
||||
it("should destroy IDs listed for remove", function()
|
||||
local root = Instance.new("Folder")
|
||||
|
||||
local child = Instance.new("Folder")
|
||||
child.Name = "Child"
|
||||
child.Parent = root
|
||||
|
||||
local instanceMap = InstanceMap.new()
|
||||
instanceMap:insert("ROOT", root)
|
||||
instanceMap:insert("CHILD", child)
|
||||
|
||||
local patch = PatchSet.newEmpty()
|
||||
table.insert(patch.removed, "CHILD")
|
||||
|
||||
local unapplied = applyPatch(instanceMap, patch)
|
||||
assert(PatchSet.isEmpty(unapplied), "expected remaining patch to be empty")
|
||||
expect(instanceMap:size()).to.equal(1)
|
||||
|
||||
assert(not wasDestroyed(root), "expected root to be left alone")
|
||||
assert(wasDestroyed(child), "expected child to be destroyed")
|
||||
|
||||
instanceMap:stop()
|
||||
end)
|
||||
|
||||
it("should add instances to the DOM", function()
|
||||
-- Many of the details of this functionality are instead covered by
|
||||
-- tests on reify, not here.
|
||||
|
||||
local root = Instance.new("Folder")
|
||||
|
||||
local instanceMap = InstanceMap.new()
|
||||
instanceMap:insert("ROOT", root)
|
||||
|
||||
local patch = PatchSet.newEmpty()
|
||||
patch.added["CHILD"] = {
|
||||
Id = "CHILD",
|
||||
ClassName = "Model",
|
||||
Name = "Child",
|
||||
Parent = "ROOT",
|
||||
Children = {"GRANDCHILD"},
|
||||
Properties = {},
|
||||
}
|
||||
|
||||
patch.added["GRANDCHILD"] = {
|
||||
Id = "GRANDCHILD",
|
||||
ClassName = "Part",
|
||||
Name = "Grandchild",
|
||||
Parent = "CHILD",
|
||||
Children = {},
|
||||
Properties = {},
|
||||
}
|
||||
|
||||
local unapplied = applyPatch(instanceMap, patch)
|
||||
assert(PatchSet.isEmpty(unapplied), "expected remaining patch to be empty")
|
||||
expect(instanceMap:size()).to.equal(3)
|
||||
|
||||
local child = root:FindFirstChild("Child")
|
||||
expect(child).to.be.ok()
|
||||
expect(child.ClassName).to.equal("Model")
|
||||
expect(child).to.equal(instanceMap.fromIds["CHILD"])
|
||||
|
||||
local grandchild = child:FindFirstChild("Grandchild")
|
||||
expect(grandchild).to.be.ok()
|
||||
expect(grandchild.ClassName).to.equal("Part")
|
||||
expect(grandchild).to.equal(instanceMap.fromIds["GRANDCHILD"])
|
||||
end)
|
||||
|
||||
it("should return unapplied additions when instances cannot be created", function()
|
||||
local root = Instance.new("Folder")
|
||||
|
||||
local instanceMap = InstanceMap.new()
|
||||
instanceMap:insert("ROOT", root)
|
||||
|
||||
local patch = PatchSet.newEmpty()
|
||||
patch.added["OOPSIE"] = {
|
||||
Id = "OOPSIE",
|
||||
-- Hopefully Roblox never makes an instance with this ClassName.
|
||||
ClassName = "UH OH",
|
||||
Name = "FUBAR",
|
||||
Parent = "ROOT",
|
||||
Children = {},
|
||||
Properties = {},
|
||||
}
|
||||
|
||||
local unapplied = applyPatch(instanceMap, patch)
|
||||
expect(unapplied.added["OOPSIE"]).to.equal(patch.added["OOPSIE"])
|
||||
expect(instanceMap:size()).to.equal(1)
|
||||
expect(#root:GetChildren()).to.equal(0)
|
||||
end)
|
||||
|
||||
it("should apply property changes to instances", function()
|
||||
local value = Instance.new("StringValue")
|
||||
value.Value = "HELLO"
|
||||
|
||||
local instanceMap = InstanceMap.new()
|
||||
instanceMap:insert("VALUE", value)
|
||||
|
||||
local patch = PatchSet.newEmpty()
|
||||
table.insert(patch.updated, {
|
||||
id = "VALUE",
|
||||
changedProperties = {
|
||||
Value = {
|
||||
Type = "String",
|
||||
Value = "WORLD",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
local unapplied = applyPatch(instanceMap, patch)
|
||||
assert(PatchSet.isEmpty(unapplied), "expected remaining patch to be empty")
|
||||
expect(value.Value).to.equal("WORLD")
|
||||
end)
|
||||
|
||||
it("should recreate instances when changedClassName is set, preserving children", function()
|
||||
local root = Instance.new("Folder")
|
||||
root.Name = "Initial Root Name"
|
||||
|
||||
local child = Instance.new("Folder")
|
||||
child.Name = "Child"
|
||||
child.Parent = root
|
||||
|
||||
local instanceMap = InstanceMap.new()
|
||||
instanceMap:insert("ROOT", root)
|
||||
instanceMap:insert("CHILD", child)
|
||||
|
||||
local patch = PatchSet.newEmpty()
|
||||
table.insert(patch.updated, {
|
||||
id = "ROOT",
|
||||
changedName = "Updated Root Name",
|
||||
changedClassName = "StringValue",
|
||||
changedProperties = {
|
||||
Value = {
|
||||
Type = "String",
|
||||
Value = "I am Root",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
local unapplied = applyPatch(instanceMap, patch)
|
||||
assert(PatchSet.isEmpty(unapplied), "expected remaining patch to be empty")
|
||||
|
||||
local newRoot = instanceMap.fromIds["ROOT"]
|
||||
assert(newRoot ~= root, "expected instance to be recreated")
|
||||
expect(newRoot.ClassName).to.equal("StringValue")
|
||||
expect(newRoot.Name).to.equal("Updated Root Name")
|
||||
expect(newRoot.Value).to.equal("I am Root")
|
||||
|
||||
local newChild = newRoot:FindFirstChild("Child")
|
||||
assert(newChild ~= nil, "expected child to be present")
|
||||
assert(newChild == child, "expected child to be preserved")
|
||||
end)
|
||||
end
|
||||
39
plugin/src/Reconciler/decodeValue.lua
Normal file
39
plugin/src/Reconciler/decodeValue.lua
Normal file
@@ -0,0 +1,39 @@
|
||||
--[[
|
||||
Transforms a value encoded by rbx_dom_weak on the server side into a value
|
||||
usable by Rojo's reconciler, potentially using RbxDom.
|
||||
]]
|
||||
|
||||
local RbxDom = require(script.Parent.Parent.Parent.RbxDom)
|
||||
local Error = require(script.Parent.Error)
|
||||
|
||||
local function decodeValue(virtualValue, instanceMap)
|
||||
-- Refs are represented as IDs in the same space that Rojo's protocol uses.
|
||||
if virtualValue.Type == "Ref" then
|
||||
if virtualValue.Value == nil then
|
||||
return true, nil
|
||||
end
|
||||
|
||||
local instance = instanceMap.fromIds[virtualValue.Value]
|
||||
|
||||
if instance ~= nil then
|
||||
return true, instance
|
||||
else
|
||||
return false, Error.new(Error.RefDidNotExist, {
|
||||
virtualValue = virtualValue,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
local ok, decodedValue = RbxDom.EncodedValue.decode(virtualValue)
|
||||
|
||||
if not ok then
|
||||
return false, Error.new(Error.CannotDecodeValue, {
|
||||
virtualValue = virtualValue,
|
||||
innerError = decodedValue,
|
||||
})
|
||||
end
|
||||
|
||||
return true, decodedValue
|
||||
end
|
||||
|
||||
return decodeValue
|
||||
148
plugin/src/Reconciler/diff.lua
Normal file
148
plugin/src/Reconciler/diff.lua
Normal file
@@ -0,0 +1,148 @@
|
||||
--[[
|
||||
Defines the process for diffing a virtual DOM and the real DOM to compute a
|
||||
patch that can be later applied.
|
||||
]]
|
||||
|
||||
local Log = require(script.Parent.Parent.Parent.Log)
|
||||
local invariant = require(script.Parent.Parent.invariant)
|
||||
local getProperty = require(script.Parent.getProperty)
|
||||
local Error = require(script.Parent.Error)
|
||||
local decodeValue = require(script.Parent.decodeValue)
|
||||
|
||||
local function isEmpty(table)
|
||||
return next(table) == nil
|
||||
end
|
||||
|
||||
local function shouldDeleteUnknownInstances(virtualInstance)
|
||||
if virtualInstance.Metadata ~= nil then
|
||||
return not virtualInstance.Metadata.ignoreUnknownInstances
|
||||
else
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
local function diff(instanceMap, virtualInstances, rootId)
|
||||
local patch = {
|
||||
removed = {},
|
||||
added = {},
|
||||
updated = {},
|
||||
}
|
||||
|
||||
-- Add a virtual instance and all of its descendants to the patch, marked as
|
||||
-- being added.
|
||||
local function markIdAdded(id)
|
||||
local virtualInstance = virtualInstances[id]
|
||||
patch.added[id] = virtualInstance
|
||||
|
||||
for _, childId in ipairs(virtualInstance.Children) do
|
||||
markIdAdded(childId)
|
||||
end
|
||||
end
|
||||
|
||||
-- Internal recursive kernel for diffing an instance with the given ID.
|
||||
local function diffInternal(id)
|
||||
local virtualInstance = virtualInstances[id]
|
||||
local instance = instanceMap.fromIds[id]
|
||||
|
||||
if virtualInstance == nil then
|
||||
invariant("Cannot diff an instance not present in virtualInstances\nID: {}", id)
|
||||
end
|
||||
|
||||
if instance == nil then
|
||||
invariant("Cannot diff an instance not present in InstanceMap\nID: {}", id)
|
||||
end
|
||||
|
||||
if virtualInstance.ClassName ~= instance.ClassName then
|
||||
error("unimplemented: support changing ClassName")
|
||||
end
|
||||
|
||||
local changedName = nil
|
||||
if virtualInstance.Name ~= instance.Name then
|
||||
changedName = virtualInstance.Name
|
||||
end
|
||||
|
||||
local changedProperties = {}
|
||||
for propertyName, virtualValue in pairs(virtualInstance.Properties) do
|
||||
local ok, existingValueOrErr = getProperty(instance, propertyName)
|
||||
|
||||
if ok then
|
||||
local existingValue = existingValueOrErr
|
||||
local ok, decodedValue = decodeValue(virtualValue, instanceMap)
|
||||
|
||||
if ok then
|
||||
if existingValue ~= decodedValue then
|
||||
changedProperties[propertyName] = virtualValue
|
||||
end
|
||||
else
|
||||
Log.warn("Failed to decode property of type {}", virtualValue.Type)
|
||||
end
|
||||
else
|
||||
local err = existingValueOrErr
|
||||
|
||||
if err.kind == Error.UnknownProperty then
|
||||
Log.trace("Skipping unknown property {}.{}", err.details.className, err.details.propertyName)
|
||||
elseif err.kind == Error.UnreadableProperty then
|
||||
Log.trace("Skipping unreadable property {}.{}", err.details.className, err.details.propertyName)
|
||||
else
|
||||
return false, err
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if changedName ~= nil or not isEmpty(changedProperties) then
|
||||
table.insert(patch.updated, {
|
||||
id = id,
|
||||
changedName = changedName,
|
||||
changedClassName = nil,
|
||||
changedProperties = changedProperties,
|
||||
changedMetadata = nil,
|
||||
})
|
||||
end
|
||||
|
||||
-- Traverse the list of children in the DOM. Any instance that has no
|
||||
-- corresponding virtual instance should be removed. Any instance that
|
||||
-- does have a corresponding virtual instance is recursively diffed.
|
||||
for _, childInstance in ipairs(instance:GetChildren()) do
|
||||
local childId = instanceMap.fromInstances[childInstance]
|
||||
|
||||
if childId == nil then
|
||||
-- This is an existing instance not present in the virtual DOM.
|
||||
-- We can mark it for deletion unless the user has asked us not
|
||||
-- to delete unknown stuff.
|
||||
if shouldDeleteUnknownInstances(virtualInstance) then
|
||||
table.insert(patch.removed, childInstance)
|
||||
end
|
||||
else
|
||||
local ok, err = diffInternal(childId)
|
||||
|
||||
if not ok then
|
||||
return false, err
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Traverse the list of children in the virtual DOM. Any virtual
|
||||
-- instance that has no corresponding real instance should be created.
|
||||
for _, childId in ipairs(virtualInstance.Children) do
|
||||
local childInstance = instanceMap.fromIds[childId]
|
||||
|
||||
if childInstance == nil then
|
||||
-- This instance is present in the virtual DOM, but doesn't
|
||||
-- exist in the real DOM.
|
||||
markIdAdded(childId)
|
||||
end
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
local ok, err = diffInternal(rootId)
|
||||
|
||||
if not ok then
|
||||
return false, err
|
||||
end
|
||||
|
||||
return true, patch
|
||||
end
|
||||
|
||||
return diff
|
||||
292
plugin/src/Reconciler/diff.spec.lua
Normal file
292
plugin/src/Reconciler/diff.spec.lua
Normal file
@@ -0,0 +1,292 @@
|
||||
return function()
|
||||
local Log = require(script.Parent.Parent.Parent.Log)
|
||||
|
||||
local InstanceMap = require(script.Parent.Parent.InstanceMap)
|
||||
local PatchSet = require(script.Parent.Parent.PatchSet)
|
||||
|
||||
local diff = require(script.Parent.diff)
|
||||
|
||||
local function isEmpty(table)
|
||||
return next(table) == nil, "Table was not empty"
|
||||
end
|
||||
|
||||
local function size(dict)
|
||||
local len = 0
|
||||
|
||||
for _ in pairs(dict) do
|
||||
len = len + 1
|
||||
end
|
||||
|
||||
return len
|
||||
end
|
||||
|
||||
it("should generate an empty patch for empty instances", function()
|
||||
local knownInstances = InstanceMap.new()
|
||||
local virtualInstances = {
|
||||
ROOT = {
|
||||
ClassName = "Folder",
|
||||
Name = "Some Name",
|
||||
Properties = {},
|
||||
Children = {},
|
||||
},
|
||||
}
|
||||
|
||||
local rootInstance = Instance.new("Folder")
|
||||
rootInstance.Name = "Some Name"
|
||||
knownInstances:insert("ROOT", rootInstance)
|
||||
|
||||
local ok, patch = diff(knownInstances, virtualInstances, "ROOT")
|
||||
|
||||
assert(ok, tostring(patch))
|
||||
|
||||
assert(isEmpty(patch.removed))
|
||||
assert(isEmpty(patch.added))
|
||||
assert(isEmpty(patch.updated))
|
||||
end)
|
||||
|
||||
it("should generate a patch with a changed name", function()
|
||||
local knownInstances = InstanceMap.new()
|
||||
local virtualInstances = {
|
||||
ROOT = {
|
||||
ClassName = "Folder",
|
||||
Name = "Some Name",
|
||||
Properties = {},
|
||||
Children = {},
|
||||
},
|
||||
}
|
||||
|
||||
local rootInstance = Instance.new("Folder")
|
||||
knownInstances:insert("ROOT", rootInstance)
|
||||
|
||||
local ok, patch = diff(knownInstances, virtualInstances, "ROOT")
|
||||
|
||||
assert(ok, tostring(patch))
|
||||
|
||||
assert(isEmpty(patch.removed))
|
||||
assert(isEmpty(patch.added))
|
||||
expect(#patch.updated).to.equal(1)
|
||||
|
||||
local update = patch.updated[1]
|
||||
expect(update.id).to.equal("ROOT")
|
||||
expect(update.changedName).to.equal("Some Name")
|
||||
assert(isEmpty(update.changedProperties))
|
||||
end)
|
||||
|
||||
it("should generate a patch with a changed property", function()
|
||||
local knownInstances = InstanceMap.new()
|
||||
local virtualInstances = {
|
||||
ROOT = {
|
||||
ClassName = "StringValue",
|
||||
Name = "Value",
|
||||
Properties = {
|
||||
Value = {
|
||||
Type = "String",
|
||||
Value = "Hello, world!",
|
||||
},
|
||||
},
|
||||
Children = {},
|
||||
},
|
||||
}
|
||||
|
||||
local rootInstance = Instance.new("StringValue")
|
||||
rootInstance.Value = "Initial Value"
|
||||
knownInstances:insert("ROOT", rootInstance)
|
||||
|
||||
local ok, patch = diff(knownInstances, virtualInstances, "ROOT")
|
||||
|
||||
assert(ok, tostring(patch))
|
||||
|
||||
assert(isEmpty(patch.removed))
|
||||
assert(isEmpty(patch.added))
|
||||
expect(#patch.updated).to.equal(1)
|
||||
|
||||
local update = patch.updated[1]
|
||||
expect(update.id).to.equal("ROOT")
|
||||
expect(update.changedName).to.equal(nil)
|
||||
expect(size(update.changedProperties)).to.equal(1)
|
||||
|
||||
local patchProperty = update.changedProperties["Value"]
|
||||
expect(patchProperty).to.be.a("table")
|
||||
expect(patchProperty.Type).to.equal("String")
|
||||
expect(patchProperty.Value).to.equal("Hello, world!")
|
||||
end)
|
||||
|
||||
it("should generate an empty patch if no properties changed", function()
|
||||
local knownInstances = InstanceMap.new()
|
||||
local virtualInstances = {
|
||||
ROOT = {
|
||||
ClassName = "StringValue",
|
||||
Name = "Value",
|
||||
Properties = {
|
||||
Value = {
|
||||
Type = "String",
|
||||
Value = "Hello, world!",
|
||||
},
|
||||
},
|
||||
Children = {},
|
||||
},
|
||||
}
|
||||
|
||||
local rootInstance = Instance.new("StringValue")
|
||||
rootInstance.Value = "Hello, world!"
|
||||
knownInstances:insert("ROOT", rootInstance)
|
||||
|
||||
local ok, patch = diff(knownInstances, virtualInstances, "ROOT")
|
||||
|
||||
assert(ok, tostring(patch))
|
||||
assert(PatchSet.isEmpty(patch), "expected empty patch")
|
||||
end)
|
||||
|
||||
it("should ignore unknown properties", function()
|
||||
local knownInstances = InstanceMap.new()
|
||||
local virtualInstances = {
|
||||
ROOT = {
|
||||
ClassName = "Folder",
|
||||
Name = "Folder",
|
||||
Properties = {
|
||||
FAKE_PROPERTY = {
|
||||
Type = "String",
|
||||
Value = "Hello, world!",
|
||||
},
|
||||
},
|
||||
Children = {},
|
||||
},
|
||||
}
|
||||
|
||||
local rootInstance = Instance.new("Folder")
|
||||
knownInstances:insert("ROOT", rootInstance)
|
||||
|
||||
local ok, patch = diff(knownInstances, virtualInstances, "ROOT")
|
||||
|
||||
assert(ok, tostring(patch))
|
||||
|
||||
assert(isEmpty(patch.removed))
|
||||
assert(isEmpty(patch.added))
|
||||
assert(isEmpty(patch.updated))
|
||||
end)
|
||||
|
||||
--[[
|
||||
Because rbx_dom_lua resolves non-canonical properties to their canonical
|
||||
variants, this test does not work as intended.
|
||||
|
||||
Instead, heat_xml is diffed with Heat, the canonical property variant,
|
||||
and a patch trying to assign to heat_xml is generated. This is
|
||||
incorrect, but will require more invasive changes to fix later.
|
||||
]]
|
||||
itFIXME("should ignore unreadable properties", function()
|
||||
local knownInstances = InstanceMap.new()
|
||||
local virtualInstances = {
|
||||
ROOT = {
|
||||
ClassName = "Fire",
|
||||
Name = "Fire",
|
||||
Properties = {
|
||||
-- heat_xml is a serialization-only property that is not
|
||||
-- exposed to Lua.
|
||||
heat_xml = {
|
||||
Type = "Float32",
|
||||
Value = 5,
|
||||
},
|
||||
},
|
||||
Children = {},
|
||||
},
|
||||
}
|
||||
|
||||
local rootInstance = Instance.new("Fire")
|
||||
knownInstances:insert("ROOT", rootInstance)
|
||||
|
||||
local ok, patch = diff(knownInstances, virtualInstances, "ROOT")
|
||||
|
||||
Log.warn("{:#?}", patch)
|
||||
|
||||
assert(ok, tostring(patch))
|
||||
|
||||
assert(isEmpty(patch.removed))
|
||||
assert(isEmpty(patch.added))
|
||||
assert(isEmpty(patch.updated))
|
||||
end)
|
||||
|
||||
it("should generate a patch removing unknown children by default", function()
|
||||
local knownInstances = InstanceMap.new()
|
||||
local virtualInstances = {
|
||||
ROOT = {
|
||||
ClassName = "Folder",
|
||||
Name = "Folder",
|
||||
Properties = {},
|
||||
Children = {},
|
||||
},
|
||||
}
|
||||
|
||||
local rootInstance = Instance.new("Folder")
|
||||
local unknownChild = Instance.new("Folder")
|
||||
unknownChild.Parent = rootInstance
|
||||
knownInstances:insert("ROOT", rootInstance)
|
||||
|
||||
local ok, patch = diff(knownInstances, virtualInstances, "ROOT")
|
||||
|
||||
assert(ok, tostring(patch))
|
||||
|
||||
assert(isEmpty(patch.added))
|
||||
assert(isEmpty(patch.updated))
|
||||
expect(#patch.removed).to.equal(1)
|
||||
expect(patch.removed[1]).to.equal(unknownChild)
|
||||
end)
|
||||
|
||||
it("should generate an empty patch if unknown children should be ignored", function()
|
||||
local knownInstances = InstanceMap.new()
|
||||
local virtualInstances = {
|
||||
ROOT = {
|
||||
ClassName = "Folder",
|
||||
Name = "Folder",
|
||||
Properties = {},
|
||||
Children = {},
|
||||
Metadata = {
|
||||
ignoreUnknownInstances = true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
local rootInstance = Instance.new("Folder")
|
||||
local unknownChild = Instance.new("Folder")
|
||||
unknownChild.Parent = rootInstance
|
||||
knownInstances:insert("ROOT", rootInstance)
|
||||
|
||||
local ok, patch = diff(knownInstances, virtualInstances, "ROOT")
|
||||
|
||||
assert(ok, tostring(patch))
|
||||
|
||||
assert(isEmpty(patch.added))
|
||||
assert(isEmpty(patch.updated))
|
||||
assert(isEmpty(patch.removed))
|
||||
end)
|
||||
|
||||
it("should generate a patch with an added child", function()
|
||||
local knownInstances = InstanceMap.new()
|
||||
local virtualInstances = {
|
||||
ROOT = {
|
||||
ClassName = "Folder",
|
||||
Name = "Folder",
|
||||
Properties = {},
|
||||
Children = {"CHILD"},
|
||||
},
|
||||
|
||||
CHILD = {
|
||||
ClassName = "Folder",
|
||||
Name = "Child",
|
||||
Properties = {},
|
||||
Children = {},
|
||||
},
|
||||
}
|
||||
|
||||
local rootInstance = Instance.new("Folder")
|
||||
knownInstances:insert("ROOT", rootInstance)
|
||||
|
||||
local ok, patch = diff(knownInstances, virtualInstances, "ROOT")
|
||||
|
||||
assert(ok, tostring(patch))
|
||||
|
||||
assert(isEmpty(patch.updated))
|
||||
assert(isEmpty(patch.removed))
|
||||
expect(size(patch.added)).to.equal(1)
|
||||
expect(patch.added["CHILD"]).to.equal(virtualInstances["CHILD"])
|
||||
end)
|
||||
end
|
||||
@@ -1,9 +1,11 @@
|
||||
local RbxDom = require(script.Parent.Parent.RbxDom)
|
||||
|
||||
--[[
|
||||
Attempts to set a property on the given instance.
|
||||
Attempts to read a property from the given instance.
|
||||
]]
|
||||
local function getCanonincalProperty(instance, propertyName)
|
||||
|
||||
local RbxDom = require(script.Parent.Parent.Parent.RbxDom)
|
||||
local Error = require(script.Parent.Error)
|
||||
|
||||
local function getProperty(instance, propertyName)
|
||||
local descriptor = RbxDom.findCanonicalPropertyDescriptor(instance.ClassName, propertyName)
|
||||
|
||||
-- We can skip unknown properties; they're not likely reflected to Lua.
|
||||
@@ -11,11 +13,17 @@ local function getCanonincalProperty(instance, propertyName)
|
||||
-- A good example of a property like this is `Model.ModelInPrimary`, which
|
||||
-- is serialized but not reflected to Lua.
|
||||
if descriptor == nil then
|
||||
return false, "unknown property"
|
||||
return false, Error.new(Error.UnknownProperty, {
|
||||
className = instance.ClassName,
|
||||
propertyName = propertyName,
|
||||
})
|
||||
end
|
||||
|
||||
if descriptor.scriptability == "None" or descriptor.scriptability == "Write" then
|
||||
return false, "unreadable property"
|
||||
return false, Error.new(Error.UnreadableProperty, {
|
||||
className = instance.ClassName,
|
||||
propertyName = propertyName,
|
||||
})
|
||||
end
|
||||
|
||||
local success, valueOrErr = descriptor:read(instance)
|
||||
@@ -26,14 +34,19 @@ local function getCanonincalProperty(instance, propertyName)
|
||||
-- If we don't have permission to read a property, we can chalk that up
|
||||
-- to our database being out of date and the engine being right.
|
||||
if err.kind == RbxDom.Error.Kind.Roblox and err.extra:find("lacking permission") then
|
||||
return false, "permission error"
|
||||
return false, Error.new(Error.LackingPropertyPermissions, {
|
||||
className = instance.ClassName,
|
||||
propertyName = propertyName,
|
||||
})
|
||||
end
|
||||
|
||||
local message = ("Invalid property %s.%s: %s"):format(descriptor.className, descriptor.name, tostring(err))
|
||||
error(message, 2)
|
||||
return false, Error.new(Error.OtherPropertyError, {
|
||||
className = instance.ClassName,
|
||||
propertyName = propertyName,
|
||||
})
|
||||
end
|
||||
|
||||
return true, valueOrErr
|
||||
end
|
||||
|
||||
return getCanonincalProperty
|
||||
return getProperty
|
||||
50
plugin/src/Reconciler/hydrate.lua
Normal file
50
plugin/src/Reconciler/hydrate.lua
Normal file
@@ -0,0 +1,50 @@
|
||||
--[[
|
||||
Defines the process of "hydration" -- matching up a virtual DOM with
|
||||
concrete instances and assigning them IDs.
|
||||
]]
|
||||
|
||||
local invariant = require(script.Parent.Parent.invariant)
|
||||
|
||||
local function hydrate(instanceMap, virtualInstances, rootId, rootInstance)
|
||||
local virtualInstance = virtualInstances[rootId]
|
||||
|
||||
if virtualInstance == nil then
|
||||
invariant("Cannot hydrate an instance not present in virtualInstances\nID: {}", rootId)
|
||||
end
|
||||
|
||||
instanceMap:insert(rootId, rootInstance)
|
||||
|
||||
local existingChildren = rootInstance:GetChildren()
|
||||
|
||||
-- For each existing child, we'll track whether it's been paired with an
|
||||
-- instance that the Rojo server knows about.
|
||||
local isExistingChildVisited = {}
|
||||
for i = 1, #existingChildren do
|
||||
isExistingChildVisited[i] = false
|
||||
end
|
||||
|
||||
for _, childId in ipairs(virtualInstance.Children) do
|
||||
local virtualChild = virtualInstances[childId]
|
||||
|
||||
for childIndex, childInstance in ipairs(existingChildren) do
|
||||
if not isExistingChildVisited[childIndex] then
|
||||
-- We guard accessing Name and ClassName in order to avoid
|
||||
-- tripping over children of DataModel that Rojo won't have
|
||||
-- permissions to access at all.
|
||||
local ok, name, className = pcall(function()
|
||||
return childInstance.Name, childInstance.ClassName
|
||||
end)
|
||||
|
||||
-- This rule is very conservative and could be loosened in the
|
||||
-- future, or more heuristics could be introduced.
|
||||
if ok and name == virtualChild.Name and className == virtualChild.ClassName then
|
||||
isExistingChildVisited[childIndex] = true
|
||||
hydrate(instanceMap, virtualInstances, childId, childInstance)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return hydrate
|
||||
129
plugin/src/Reconciler/hydrate.spec.lua
Normal file
129
plugin/src/Reconciler/hydrate.spec.lua
Normal file
@@ -0,0 +1,129 @@
|
||||
return function()
|
||||
local hydrate = require(script.Parent.hydrate)
|
||||
|
||||
local InstanceMap = require(script.Parent.Parent.InstanceMap)
|
||||
|
||||
it("should match the root instance no matter what", function()
|
||||
local knownInstances = InstanceMap.new()
|
||||
local virtualInstances = {
|
||||
ROOT = {
|
||||
ClassName = "Model",
|
||||
Name = "Foo",
|
||||
Properties = {},
|
||||
Children = {},
|
||||
},
|
||||
}
|
||||
|
||||
local rootInstance = Instance.new("Folder")
|
||||
|
||||
hydrate(knownInstances, virtualInstances, "ROOT", rootInstance)
|
||||
|
||||
expect(knownInstances:size()).to.equal(1)
|
||||
expect(knownInstances.fromIds["ROOT"]).to.equal(rootInstance)
|
||||
end)
|
||||
|
||||
it("should not match children with mismatched ClassName", function()
|
||||
local knownInstances = InstanceMap.new()
|
||||
local virtualInstances = {
|
||||
ROOT = {
|
||||
ClassName = "Folder",
|
||||
Name = "Root",
|
||||
Properties = {},
|
||||
Children = {},
|
||||
},
|
||||
|
||||
CHILD = {
|
||||
ClassName = "Folder",
|
||||
Name = "Child",
|
||||
Properties = {},
|
||||
Children = {},
|
||||
},
|
||||
}
|
||||
|
||||
local rootInstance = Instance.new("Folder")
|
||||
|
||||
-- ClassName of this instance is intentionally different
|
||||
local child = Instance.new("Model")
|
||||
child.Name = "Child"
|
||||
child.Parent = rootInstance
|
||||
|
||||
hydrate(knownInstances, virtualInstances, "ROOT", rootInstance)
|
||||
|
||||
expect(knownInstances:size()).to.equal(1)
|
||||
expect(knownInstances.fromIds["ROOT"]).to.equal(rootInstance)
|
||||
end)
|
||||
|
||||
it("should not match children with mismatched Name", function()
|
||||
local knownInstances = InstanceMap.new()
|
||||
local virtualInstances = {
|
||||
ROOT = {
|
||||
ClassName = "Folder",
|
||||
Name = "Root",
|
||||
Properties = {},
|
||||
Children = {},
|
||||
},
|
||||
|
||||
CHILD = {
|
||||
ClassName = "Folder",
|
||||
Name = "Child",
|
||||
Properties = {},
|
||||
Children = {},
|
||||
},
|
||||
}
|
||||
|
||||
local rootInstance = Instance.new("Folder")
|
||||
|
||||
-- Name of this instance is intentionally different
|
||||
local child = Instance.new("Folder")
|
||||
child.Name = "Not Child"
|
||||
child.Parent = rootInstance
|
||||
|
||||
hydrate(knownInstances, virtualInstances, "ROOT", rootInstance)
|
||||
|
||||
expect(knownInstances:size()).to.equal(1)
|
||||
expect(knownInstances.fromIds["ROOT"]).to.equal(rootInstance)
|
||||
end)
|
||||
|
||||
it("should pair instances with matching Name and ClassName", function()
|
||||
local knownInstances = InstanceMap.new()
|
||||
local virtualInstances = {
|
||||
ROOT = {
|
||||
ClassName = "Folder",
|
||||
Name = "Root",
|
||||
Properties = {},
|
||||
Children = {"CHILD1", "CHILD2"},
|
||||
},
|
||||
|
||||
CHILD1 = {
|
||||
ClassName = "Folder",
|
||||
Name = "Child 1",
|
||||
Properties = {},
|
||||
Children = {},
|
||||
},
|
||||
|
||||
CHILD2 = {
|
||||
ClassName = "Model",
|
||||
Name = "Child 2",
|
||||
Properties = {},
|
||||
Children = {},
|
||||
},
|
||||
}
|
||||
|
||||
local rootInstance = Instance.new("Folder")
|
||||
|
||||
local child1 = Instance.new("Folder")
|
||||
child1.Name = "Child 1"
|
||||
child1.Parent = rootInstance
|
||||
|
||||
local child2 = Instance.new("Model")
|
||||
child2.Name = "Child 2"
|
||||
child2.Parent = rootInstance
|
||||
|
||||
hydrate(knownInstances, virtualInstances, "ROOT", rootInstance)
|
||||
|
||||
expect(knownInstances:size()).to.equal(3)
|
||||
expect(knownInstances.fromIds["ROOT"]).to.equal(rootInstance)
|
||||
expect(knownInstances.fromIds["CHILD1"]).to.equal(child1)
|
||||
expect(knownInstances.fromIds["CHILD2"]).to.equal(child2)
|
||||
end)
|
||||
end
|
||||
34
plugin/src/Reconciler/init.lua
Normal file
34
plugin/src/Reconciler/init.lua
Normal file
@@ -0,0 +1,34 @@
|
||||
--[[
|
||||
This module defines the meat of the Rojo plugin and how it manages tracking
|
||||
and mutating the Roblox DOM.
|
||||
]]
|
||||
|
||||
local applyPatch = require(script.applyPatch)
|
||||
local hydrate = require(script.hydrate)
|
||||
local diff = require(script.diff)
|
||||
|
||||
local Reconciler = {}
|
||||
Reconciler.__index = Reconciler
|
||||
|
||||
function Reconciler.new(instanceMap)
|
||||
local self = {
|
||||
-- Tracks all of the instances known by the reconciler by ID.
|
||||
__instanceMap = instanceMap,
|
||||
}
|
||||
|
||||
return setmetatable(self, Reconciler)
|
||||
end
|
||||
|
||||
function Reconciler:applyPatch(patch)
|
||||
return applyPatch(self.__instanceMap, patch)
|
||||
end
|
||||
|
||||
function Reconciler:hydrate(virtualInstances, rootId, rootInstance)
|
||||
return hydrate(self.__instanceMap, virtualInstances, rootId, rootInstance)
|
||||
end
|
||||
|
||||
function Reconciler:diff(virtualInstances, rootId)
|
||||
return diff(self.__instanceMap, virtualInstances, rootId)
|
||||
end
|
||||
|
||||
return Reconciler
|
||||
158
plugin/src/Reconciler/reify.lua
Normal file
158
plugin/src/Reconciler/reify.lua
Normal file
@@ -0,0 +1,158 @@
|
||||
--[[
|
||||
"Reifies" a virtual DOM, constructing a real DOM with the same shape.
|
||||
]]
|
||||
|
||||
local invariant = require(script.Parent.Parent.invariant)
|
||||
local PatchSet = require(script.Parent.Parent.PatchSet)
|
||||
local setProperty = require(script.Parent.setProperty)
|
||||
local decodeValue = require(script.Parent.decodeValue)
|
||||
|
||||
local reifyInner, applyDeferredRefs
|
||||
|
||||
local function reify(instanceMap, virtualInstances, rootId, parentInstance)
|
||||
-- Create an empty patch that will be populated with any parts of this reify
|
||||
-- that could not happen, like instances that couldn't be created and
|
||||
-- properties that could not be assigned.
|
||||
local unappliedPatch = PatchSet.newEmpty()
|
||||
|
||||
-- Contains a list of all of the ref properties that we'll need to assign
|
||||
-- after all instances are created. We apply refs in a second pass, after
|
||||
-- we create as many instances as we can, so that we ensure that referents
|
||||
-- can be mapped to instances correctly.
|
||||
local deferredRefs = {}
|
||||
|
||||
reifyInner(instanceMap, virtualInstances, rootId, parentInstance, unappliedPatch, deferredRefs)
|
||||
applyDeferredRefs(instanceMap, deferredRefs, unappliedPatch)
|
||||
|
||||
return unappliedPatch
|
||||
end
|
||||
|
||||
--[[
|
||||
Add the given ID and all of its descendants in virtualInstances to the given
|
||||
PatchSet, marked for addition.
|
||||
]]
|
||||
local function addAllToPatch(patchSet, virtualInstances, id)
|
||||
local virtualInstance = virtualInstances[id]
|
||||
patchSet.added[id] = virtualInstance
|
||||
|
||||
for _, childId in ipairs(virtualInstance.Children) do
|
||||
addAllToPatch(patchSet, virtualInstances, childId)
|
||||
end
|
||||
end
|
||||
|
||||
--[[
|
||||
Inner function that defines the core routine.
|
||||
]]
|
||||
function reifyInner(instanceMap, virtualInstances, id, parentInstance, unappliedPatch, deferredRefs)
|
||||
local virtualInstance = virtualInstances[id]
|
||||
|
||||
if virtualInstance == nil then
|
||||
invariant("Cannot reify an instance not present in virtualInstances\nID: {}", id)
|
||||
end
|
||||
|
||||
-- Instance.new can fail if we're passing in something that can't be
|
||||
-- created, like a service, something enabled with a feature flag, or
|
||||
-- something that requires higher security than we have.
|
||||
local ok, instance = pcall(Instance.new, virtualInstance.ClassName)
|
||||
|
||||
if not ok then
|
||||
addAllToPatch(unappliedPatch, virtualInstances, id)
|
||||
return
|
||||
end
|
||||
|
||||
-- TODO: Can this fail? Previous versions of Rojo guarded against this, but
|
||||
-- the reason why was uncertain.
|
||||
instance.Name = virtualInstance.Name
|
||||
|
||||
-- Track all of the properties that we've failed to assign to this instance.
|
||||
local unappliedProperties = {}
|
||||
|
||||
for propertyName, virtualValue in pairs(virtualInstance.Properties) do
|
||||
-- Because refs may refer to instances that we haven't constructed yet,
|
||||
-- we defer applying any ref properties until all instances are created.
|
||||
if virtualValue.Type == "Ref" then
|
||||
table.insert(deferredRefs, {
|
||||
id = id,
|
||||
instance = instance,
|
||||
propertyName = propertyName,
|
||||
virtualValue = virtualValue,
|
||||
})
|
||||
continue
|
||||
end
|
||||
|
||||
local ok, value = decodeValue(virtualValue, instanceMap)
|
||||
if not ok then
|
||||
unappliedProperties[propertyName] = virtualValue
|
||||
continue
|
||||
end
|
||||
|
||||
local ok = setProperty(instance, propertyName, value)
|
||||
if not ok then
|
||||
unappliedProperties[propertyName] = virtualValue
|
||||
end
|
||||
end
|
||||
|
||||
-- If there were any properties that we failed to assign, push this into our
|
||||
-- unapplied patch as an update that would need to be applied.
|
||||
if next(unappliedProperties) ~= nil then
|
||||
table.insert(unappliedPatch.updated, {
|
||||
id = id,
|
||||
changedProperties = unappliedProperties,
|
||||
})
|
||||
end
|
||||
|
||||
for _, childId in ipairs(virtualInstance.Children) do
|
||||
reifyInner(instanceMap, virtualInstances, childId, instance, unappliedPatch, deferredRefs)
|
||||
end
|
||||
|
||||
instance.Parent = parentInstance
|
||||
instanceMap:insert(id, instance)
|
||||
end
|
||||
|
||||
function applyDeferredRefs(instanceMap, deferredRefs, unappliedPatch)
|
||||
local function markFailed(id, propertyName, virtualValue)
|
||||
-- If there is already an updated entry in the unapplied patch for this
|
||||
-- ref, use the existing one. This could match other parts of the
|
||||
-- instance that failed to be created, or even just other refs that
|
||||
-- failed to apply.
|
||||
--
|
||||
-- This is important for instances like selectable GUI objects, which
|
||||
-- have many similar referent properties.
|
||||
for _, existingUpdate in ipairs(unappliedPatch.updated) do
|
||||
if existingUpdate.id == id then
|
||||
existingUpdate.changedProperties[propertyName] = virtualValue
|
||||
return
|
||||
end
|
||||
end
|
||||
|
||||
-- We didn't find an existing entry that matched, so push a new entry
|
||||
-- into our unapplied patch.
|
||||
table.insert(unappliedPatch.updated, {
|
||||
id = id,
|
||||
changedProperties = {
|
||||
[propertyName] = virtualValue,
|
||||
},
|
||||
})
|
||||
end
|
||||
|
||||
for _, entry in ipairs(deferredRefs) do
|
||||
local virtualValue = entry.virtualValue
|
||||
|
||||
if virtualValue.Value == nil then
|
||||
continue
|
||||
end
|
||||
|
||||
local targetInstance = instanceMap.fromIds[virtualValue.Value]
|
||||
if targetInstance == nil then
|
||||
markFailed(entry.id, entry.propertyName, virtualValue)
|
||||
continue
|
||||
end
|
||||
|
||||
local ok = setProperty(entry.instance, entry.propertyName, targetInstance)
|
||||
if not ok then
|
||||
markFailed(entry.id, entry.propertyName, virtualValue)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return reify
|
||||
352
plugin/src/Reconciler/reify.spec.lua
Normal file
352
plugin/src/Reconciler/reify.spec.lua
Normal file
@@ -0,0 +1,352 @@
|
||||
return function()
|
||||
local reify = require(script.Parent.reify)
|
||||
|
||||
local PatchSet = require(script.Parent.Parent.PatchSet)
|
||||
local InstanceMap = require(script.Parent.Parent.InstanceMap)
|
||||
local Error = require(script.Parent.Error)
|
||||
|
||||
local function isEmpty(table)
|
||||
return next(table) == nil, "Table was not empty"
|
||||
end
|
||||
|
||||
local function size(dict)
|
||||
local len = 0
|
||||
|
||||
for _ in pairs(dict) do
|
||||
len = len + 1
|
||||
end
|
||||
|
||||
return len
|
||||
end
|
||||
|
||||
it("should throw when given a bogus ID", function()
|
||||
expect(function()
|
||||
reify(InstanceMap.new(), {}, "Hi, mom!", game)
|
||||
end).to.throw()
|
||||
end)
|
||||
|
||||
it("should return an error when given bogus class names", function()
|
||||
local virtualInstances = {
|
||||
ROOT = {
|
||||
ClassName = "Balogna",
|
||||
Name = "Food",
|
||||
Properties = {},
|
||||
Children = {},
|
||||
},
|
||||
}
|
||||
|
||||
local instanceMap = InstanceMap.new()
|
||||
local unappliedPatch = reify(instanceMap, virtualInstances, "ROOT", nil)
|
||||
|
||||
assert(instanceMap:size() == 0, "expected instanceMap to be empty")
|
||||
|
||||
expect(size(unappliedPatch.added)).to.equal(1)
|
||||
expect(unappliedPatch.added["ROOT"]).to.equal(virtualInstances["ROOT"])
|
||||
|
||||
assert(isEmpty(unappliedPatch.removed), "expected no removes")
|
||||
assert(isEmpty(unappliedPatch.updated), "expected no updates")
|
||||
end)
|
||||
|
||||
it("should assign name and properties", function()
|
||||
local virtualInstances = {
|
||||
ROOT = {
|
||||
ClassName = "StringValue",
|
||||
Name = "Spaghetti",
|
||||
Properties = {
|
||||
Value = {
|
||||
Type = "String",
|
||||
Value = "Hello, world!",
|
||||
},
|
||||
},
|
||||
Children = {},
|
||||
},
|
||||
}
|
||||
|
||||
local instanceMap = InstanceMap.new()
|
||||
local unappliedPatch = reify(instanceMap, virtualInstances, "ROOT")
|
||||
|
||||
assert(PatchSet.isEmpty(unappliedPatch), "expected remaining patch to be empty")
|
||||
|
||||
local instance = instanceMap.fromIds["ROOT"]
|
||||
expect(instance.ClassName).to.equal("StringValue")
|
||||
expect(instance.Name).to.equal("Spaghetti")
|
||||
expect(instance.Value).to.equal("Hello, world!")
|
||||
|
||||
expect(instanceMap:size()).to.equal(1)
|
||||
end)
|
||||
|
||||
it("should construct children", function()
|
||||
local virtualInstances = {
|
||||
ROOT = {
|
||||
ClassName = "Folder",
|
||||
Name = "Parent",
|
||||
Properties = {},
|
||||
Children = {"CHILD"},
|
||||
},
|
||||
|
||||
CHILD = {
|
||||
ClassName = "Folder",
|
||||
Name = "Child",
|
||||
Properties = {},
|
||||
Children = {},
|
||||
},
|
||||
}
|
||||
|
||||
local instanceMap = InstanceMap.new()
|
||||
local unappliedPatch = reify(instanceMap, virtualInstances, "ROOT")
|
||||
|
||||
assert(PatchSet.isEmpty(unappliedPatch), "expected remaining patch to be empty")
|
||||
|
||||
local instance = instanceMap.fromIds["ROOT"]
|
||||
expect(instance.ClassName).to.equal("Folder")
|
||||
expect(instance.Name).to.equal("Parent")
|
||||
|
||||
local child = instance.Child
|
||||
expect(child.ClassName).to.equal("Folder")
|
||||
|
||||
expect(instanceMap:size()).to.equal(2)
|
||||
end)
|
||||
|
||||
it("should still construct parents if children fail", function()
|
||||
local virtualInstances = {
|
||||
ROOT = {
|
||||
ClassName = "Folder",
|
||||
Name = "Parent",
|
||||
Properties = {},
|
||||
Children = {"CHILD"},
|
||||
},
|
||||
|
||||
CHILD = {
|
||||
ClassName = "this ain't an Instance",
|
||||
Name = "Child",
|
||||
Properties = {},
|
||||
Children = {},
|
||||
},
|
||||
}
|
||||
|
||||
local instanceMap = InstanceMap.new()
|
||||
local unappliedPatch = reify(instanceMap, virtualInstances, "ROOT")
|
||||
|
||||
expect(size(unappliedPatch.added)).to.equal(1)
|
||||
expect(unappliedPatch.added["CHILD"]).to.equal(virtualInstances["CHILD"])
|
||||
assert(isEmpty(unappliedPatch.updated), "expected no updates")
|
||||
assert(isEmpty(unappliedPatch.removed), "expected no removes")
|
||||
|
||||
local instance = instanceMap.fromIds["ROOT"]
|
||||
expect(instance.ClassName).to.equal("Folder")
|
||||
expect(instance.Name).to.equal("Parent")
|
||||
|
||||
expect(#instance:GetChildren()).to.equal(0)
|
||||
expect(instanceMap:size()).to.equal(1)
|
||||
end)
|
||||
|
||||
it("should fail gracefully when setting erroneous properties", function()
|
||||
local virtualInstances = {
|
||||
ROOT = {
|
||||
ClassName = "StringValue",
|
||||
Name = "Root",
|
||||
Properties = {
|
||||
Value = {
|
||||
Type = "Vector3",
|
||||
Value = {1, 2, 3},
|
||||
},
|
||||
},
|
||||
Children = {},
|
||||
},
|
||||
}
|
||||
|
||||
local instanceMap = InstanceMap.new()
|
||||
local unappliedPatch = reify(instanceMap, virtualInstances, "ROOT")
|
||||
|
||||
local instance = instanceMap.fromIds["ROOT"]
|
||||
expect(instance.ClassName).to.equal("StringValue")
|
||||
expect(instance.Name).to.equal("Root")
|
||||
|
||||
assert(isEmpty(unappliedPatch.added), "expected no additions")
|
||||
expect(#unappliedPatch.updated).to.equal(1)
|
||||
assert(isEmpty(unappliedPatch.removed), "expected no removes")
|
||||
|
||||
local update = unappliedPatch.updated[1]
|
||||
expect(update.id).to.equal("ROOT")
|
||||
expect(size(update.changedProperties)).to.equal(1)
|
||||
|
||||
local property = update.changedProperties["Value"]
|
||||
expect(property).to.equal(virtualInstances["ROOT"].Properties.Value)
|
||||
end)
|
||||
|
||||
-- This is the simplest ref case: ensure that setting a ref property that
|
||||
-- points to an instance that was previously created as part of the same
|
||||
-- reify operation works.
|
||||
it("should apply properties containing refs to ancestors", function()
|
||||
local virtualInstances = {
|
||||
ROOT = {
|
||||
ClassName = "Folder",
|
||||
Name = "Root",
|
||||
Properties = {},
|
||||
Children = {"CHILD"},
|
||||
},
|
||||
|
||||
CHILD = {
|
||||
ClassName = "ObjectValue",
|
||||
Name = "Child",
|
||||
Properties = {
|
||||
Value = {
|
||||
Type = "Ref",
|
||||
Value = "ROOT",
|
||||
},
|
||||
},
|
||||
Children = {},
|
||||
},
|
||||
}
|
||||
|
||||
local instanceMap = InstanceMap.new()
|
||||
local unappliedPatch = reify(instanceMap, virtualInstances, "ROOT")
|
||||
|
||||
assert(PatchSet.isEmpty(unappliedPatch), "expected remaining patch to be empty")
|
||||
|
||||
local root = instanceMap.fromIds["ROOT"]
|
||||
local child = instanceMap.fromIds["CHILD"]
|
||||
expect(child.Value).to.equal(root)
|
||||
end)
|
||||
|
||||
-- This is another simple case: apply a ref property that points to an
|
||||
-- existing instance. In this test, that instance was created before the
|
||||
-- reify operation started and is present in instanceMap.
|
||||
it("should apply properties containing refs to previously-existing instances", function()
|
||||
local virtualInstances = {
|
||||
ROOT = {
|
||||
ClassName = "ObjectValue",
|
||||
Name = "Root",
|
||||
Properties = {
|
||||
Value = {
|
||||
Type = "Ref",
|
||||
Value = "EXISTING",
|
||||
},
|
||||
},
|
||||
Children = {},
|
||||
},
|
||||
}
|
||||
|
||||
local instanceMap = InstanceMap.new()
|
||||
|
||||
local existing = Instance.new("Folder")
|
||||
existing.Name = "Existing"
|
||||
instanceMap:insert("EXISTING", existing)
|
||||
|
||||
local unappliedPatch = reify(instanceMap, virtualInstances, "ROOT")
|
||||
|
||||
assert(PatchSet.isEmpty(unappliedPatch), "expected remaining patch to be empty")
|
||||
|
||||
local root = instanceMap.fromIds["ROOT"]
|
||||
expect(root.Value).to.equal(existing)
|
||||
end)
|
||||
|
||||
-- This is a tricky ref case: CHILD_A points to CHILD_B, but is constructed
|
||||
-- first. Deferred ref application is required to implement this case
|
||||
-- correctly.
|
||||
it("should apply properties containing refs to later siblings correctly", function()
|
||||
local virtualInstances = {
|
||||
ROOT = {
|
||||
ClassName = "Folder",
|
||||
Name = "Root",
|
||||
Properties = {},
|
||||
Children = {"CHILD_A", "CHILD_B"},
|
||||
},
|
||||
|
||||
CHILD_A = {
|
||||
ClassName = "ObjectValue",
|
||||
Name = "Child A",
|
||||
Properties = {
|
||||
Value = {
|
||||
Type = "Ref",
|
||||
Value = "Child B",
|
||||
},
|
||||
},
|
||||
Children = {},
|
||||
},
|
||||
|
||||
CHILD_B = {
|
||||
ClassName = "Folder",
|
||||
Name = "Child B",
|
||||
Properties = {},
|
||||
Children = {},
|
||||
},
|
||||
}
|
||||
|
||||
local instanceMap = InstanceMap.new()
|
||||
local unappliedPatch = reify(instanceMap, virtualInstances, "ROOT")
|
||||
|
||||
assert(PatchSet.isEmpty(unappliedPatch), "expected remaining patch to be empty")
|
||||
|
||||
local childA = instanceMap.fromIds["CHILD_A"]
|
||||
local childB = instanceMap.fromIds["CHILD_B"]
|
||||
expect(childA.Value).to.equal(childB)
|
||||
end)
|
||||
|
||||
-- This is the classic case that calls for deferred ref application. In this
|
||||
-- test, the root instance has a ref property that refers to its child. The
|
||||
-- root is definitely constructed first.
|
||||
--
|
||||
-- This is distinct from the sibling case in that the child will be
|
||||
-- constructed as part of a recursive call before the parent has totally
|
||||
-- finished. Given deferred refs, this should not fail, but it is a good
|
||||
-- case to test.
|
||||
it("should apply properties containing refs to later siblings correctly", function()
|
||||
local virtualInstances = {
|
||||
ROOT = {
|
||||
ClassName = "ObjectValue",
|
||||
Name = "Root",
|
||||
Properties = {
|
||||
Value = {
|
||||
Type = "Ref",
|
||||
Value = "CHILD",
|
||||
},
|
||||
},
|
||||
Children = {"CHILD"},
|
||||
},
|
||||
|
||||
CHILD = {
|
||||
ClassName = "Folder",
|
||||
Name = "Child",
|
||||
Properties = {},
|
||||
Children = {},
|
||||
},
|
||||
}
|
||||
|
||||
local instanceMap = InstanceMap.new()
|
||||
local unappliedPatch = reify(instanceMap, virtualInstances, "ROOT")
|
||||
|
||||
assert(PatchSet.isEmpty(unappliedPatch), "expected remaining patch to be empty")
|
||||
|
||||
local root = instanceMap.fromIds["ROOT"]
|
||||
local child = instanceMap.fromIds["CHILD"]
|
||||
expect(root.Value).to.equal(child)
|
||||
end)
|
||||
|
||||
it("should return a partial patch when applying invalid refs", function()
|
||||
local virtualInstances = {
|
||||
ROOT = {
|
||||
ClassName = "ObjectValue",
|
||||
Name = "Root",
|
||||
Properties = {
|
||||
Value = {
|
||||
Type = "Ref",
|
||||
Value = "SORRY",
|
||||
},
|
||||
},
|
||||
Children = {},
|
||||
},
|
||||
}
|
||||
|
||||
local instanceMap = InstanceMap.new()
|
||||
local unappliedPatch = reify(instanceMap, virtualInstances, "ROOT")
|
||||
|
||||
assert(not PatchSet.hasRemoves(unappliedPatch), "expected no removes")
|
||||
assert(not PatchSet.hasAdditions(unappliedPatch), "expected no additions")
|
||||
expect(#unappliedPatch.updated).to.equal(1)
|
||||
|
||||
local update = unappliedPatch.updated[1]
|
||||
expect(update.id).to.equal("ROOT")
|
||||
expect(update.changedProperties.Value).to.equal(virtualInstances["ROOT"].Properties.Value)
|
||||
end)
|
||||
end
|
||||
48
plugin/src/Reconciler/setProperty.lua
Normal file
48
plugin/src/Reconciler/setProperty.lua
Normal file
@@ -0,0 +1,48 @@
|
||||
--[[
|
||||
Attempts to set a property on the given instance.
|
||||
]]
|
||||
|
||||
local RbxDom = require(script.Parent.Parent.Parent.RbxDom)
|
||||
local Log = require(script.Parent.Parent.Parent.Log)
|
||||
local Error = require(script.Parent.Error)
|
||||
|
||||
local function setProperty(instance, propertyName, value)
|
||||
local descriptor = RbxDom.findCanonicalPropertyDescriptor(instance.ClassName, propertyName)
|
||||
|
||||
-- We can skip unknown properties; they're not likely reflected to Lua.
|
||||
--
|
||||
-- A good example of a property like this is `Model.ModelInPrimary`, which
|
||||
-- is serialized but not reflected to Lua.
|
||||
if descriptor == nil then
|
||||
Log.trace("Skipping unknown property {}.{}", instance.ClassName, propertyName)
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
if descriptor.scriptability == "None" or descriptor.scriptability == "Read" then
|
||||
return false, Error.new(Error.UnwritableProperty, {
|
||||
className = instance.ClassName,
|
||||
propertyName = propertyName,
|
||||
})
|
||||
end
|
||||
|
||||
local ok, err = descriptor:write(instance, value)
|
||||
|
||||
if not ok then
|
||||
if err.kind == RbxDom.Error.Kind.Roblox and err.extra:find("lacking permission") then
|
||||
return false, Error.new(Error.LackingPropertyPermissions, {
|
||||
className = instance.ClassName,
|
||||
propertyName = propertyName,
|
||||
})
|
||||
end
|
||||
|
||||
return false, Error.new(Error.OtherPropertyError, {
|
||||
className = instance.ClassName,
|
||||
propertyName = propertyName,
|
||||
})
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
return setProperty
|
||||
@@ -1,9 +1,11 @@
|
||||
local StudioService = game:GetService("StudioService")
|
||||
|
||||
local Log = require(script.Parent.Parent.Log)
|
||||
local Fmt = require(script.Parent.Parent.Fmt)
|
||||
local t = require(script.Parent.Parent.t)
|
||||
|
||||
local DevSettings = require(script.Parent.DevSettings)
|
||||
local InstanceMap = require(script.Parent.InstanceMap)
|
||||
local PatchSet = require(script.Parent.PatchSet)
|
||||
local Reconciler = require(script.Parent.Reconciler)
|
||||
local strict = require(script.Parent.strict)
|
||||
|
||||
@@ -43,6 +45,8 @@ ServeSession.Status = Status
|
||||
|
||||
local validateServeOptions = t.strictInterface({
|
||||
apiContext = t.table,
|
||||
openScriptsExternally = t.boolean,
|
||||
twoWaySync = t.boolean,
|
||||
})
|
||||
|
||||
function ServeSession.new(options)
|
||||
@@ -57,12 +61,28 @@ function ServeSession.new(options)
|
||||
local instanceMap = InstanceMap.new(onInstanceChanged)
|
||||
local reconciler = Reconciler.new(instanceMap)
|
||||
|
||||
local connections = {}
|
||||
|
||||
local connection = StudioService
|
||||
:GetPropertyChangedSignal("ActiveScript")
|
||||
:Connect(function()
|
||||
local activeScript = StudioService.ActiveScript
|
||||
|
||||
if activeScript ~= nil then
|
||||
self:__onActiveScriptChanged(activeScript)
|
||||
end
|
||||
end)
|
||||
table.insert(connections, connection)
|
||||
|
||||
self = {
|
||||
__status = Status.NotStarted,
|
||||
__apiContext = options.apiContext,
|
||||
__openScriptsExternally = options.openScriptsExternally,
|
||||
__twoWaySync = options.twoWaySync,
|
||||
__reconciler = reconciler,
|
||||
__instanceMap = instanceMap,
|
||||
__statusChangedCallback = nil,
|
||||
__connections = connections,
|
||||
}
|
||||
|
||||
setmetatable(self, ServeSession)
|
||||
@@ -108,8 +128,39 @@ function ServeSession:stop()
|
||||
self:__stopInternal()
|
||||
end
|
||||
|
||||
function ServeSession:__onActiveScriptChanged(activeScript)
|
||||
if not self.__openScriptsExternally then
|
||||
Log.trace("Not opening script {} because feature not enabled.", activeScript)
|
||||
|
||||
return
|
||||
end
|
||||
|
||||
if self.__status ~= Status.Connected then
|
||||
Log.trace("Not opening script {} because session is not connected.", activeScript)
|
||||
|
||||
return
|
||||
end
|
||||
|
||||
local scriptId = self.__instanceMap.fromInstances[activeScript]
|
||||
if scriptId == nil then
|
||||
Log.trace("Not opening script {} because it is not known by Rojo.", activeScript)
|
||||
|
||||
return
|
||||
end
|
||||
|
||||
Log.debug("Trying to open script {} externally...", activeScript)
|
||||
|
||||
-- Force-close the script inside Studio
|
||||
local existingParent = activeScript.Parent
|
||||
activeScript.Parent = nil
|
||||
activeScript.Parent = existingParent
|
||||
|
||||
-- Notify the Rojo server to open this script
|
||||
self.__apiContext:open(scriptId)
|
||||
end
|
||||
|
||||
function ServeSession:__onInstanceChanged(instance, propertyName)
|
||||
if not DevSettings:twoWaySyncEnabled() then
|
||||
if not self.__twoWaySync then
|
||||
return
|
||||
end
|
||||
|
||||
@@ -164,22 +215,36 @@ function ServeSession:__initialSync(rootInstanceId)
|
||||
-- the tree defined in this response.
|
||||
self.__apiContext:setMessageCursor(readResponseBody.messageCursor)
|
||||
|
||||
Log.trace("Computing changes that plugin needs to make to catch up to server...")
|
||||
-- For any instances that line up with the Rojo server's view, start
|
||||
-- tracking them in the reconciler.
|
||||
Log.trace("Matching existing Roblox instances to Rojo IDs")
|
||||
self.__reconciler:hydrate(readResponseBody.instances, rootInstanceId, game)
|
||||
|
||||
-- Calculate the initial patch to apply to the DataModel to catch us
|
||||
-- up to what Rojo thinks the place should look like.
|
||||
local hydratePatch = self.__reconciler:hydrate(
|
||||
Log.trace("Computing changes that plugin needs to make to catch up to server...")
|
||||
local success, catchUpPatch = self.__reconciler:diff(
|
||||
readResponseBody.instances,
|
||||
rootInstanceId,
|
||||
game
|
||||
)
|
||||
|
||||
Log.trace("Computed hydration patch: {:#?}", debugPatch(hydratePatch))
|
||||
if not success then
|
||||
Log.error("Could not compute a diff to catch up to the Rojo server: {:#?}", catchUpPatch)
|
||||
end
|
||||
|
||||
Log.trace("Computed hydration patch: {:#?}", debugPatch(catchUpPatch))
|
||||
|
||||
-- TODO: Prompt user to notify them of this patch, since it's
|
||||
-- effectively a conflict between the Rojo server and the client.
|
||||
-- effectively a conflict between the Rojo server and the client. In
|
||||
-- the future, we'll ask which changes the user wants to keep.
|
||||
|
||||
self.__reconciler:applyPatch(hydratePatch)
|
||||
local unappliedPatch = self.__reconciler:applyPatch(catchUpPatch)
|
||||
|
||||
if not PatchSet.isEmpty(unappliedPatch) then
|
||||
Log.warn("Could not apply all changes requested by the Rojo server:\n{}",
|
||||
PatchSet.humanSummary(self.__instanceMap, unappliedPatch))
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -187,7 +252,12 @@ function ServeSession:__mainSyncLoop()
|
||||
return self.__apiContext:retrieveMessages()
|
||||
:andThen(function(messages)
|
||||
for _, message in ipairs(messages) do
|
||||
self.__reconciler:applyPatch(message)
|
||||
local unappliedPatch = self.__reconciler:applyPatch(message)
|
||||
|
||||
if not PatchSet.isEmpty(unappliedPatch) then
|
||||
Log.warn("Could not apply all changes requested by the Rojo server:\n{}",
|
||||
PatchSet.humanSummary(self.__instanceMap, unappliedPatch))
|
||||
end
|
||||
end
|
||||
|
||||
if self.__status ~= Status.Disconnected then
|
||||
@@ -200,6 +270,11 @@ function ServeSession:__stopInternal(err)
|
||||
self:__setStatus(Status.Disconnected, err)
|
||||
self.__apiContext:disconnect()
|
||||
self.__instanceMap:stop()
|
||||
|
||||
for _, connection in ipairs(self.__connections) do
|
||||
connection:Disconnect()
|
||||
end
|
||||
self.__connections = {}
|
||||
end
|
||||
|
||||
function ServeSession:__setStatus(status, detail)
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
local strict = require(script.Parent.strict)
|
||||
|
||||
return strict("Theme", {
|
||||
ButtonFont = Enum.Font.GothamSemibold,
|
||||
InputFont = Enum.Font.Code,
|
||||
TitleFont = Enum.Font.GothamBold,
|
||||
MainFont = Enum.Font.Gotham,
|
||||
|
||||
AccentColor = Color3.fromRGB(225, 56, 53),
|
||||
AccentLightColor = Color3.fromRGB(255, 146, 145),
|
||||
PrimaryColor = Color3.fromRGB(64, 64, 64),
|
||||
SecondaryColor = Color3.fromRGB(235, 235, 235),
|
||||
LightTextColor = Color3.fromRGB(160, 160, 160),
|
||||
})
|
||||
53
plugin/src/createSignal.lua
Normal file
53
plugin/src/createSignal.lua
Normal file
@@ -0,0 +1,53 @@
|
||||
--[[
|
||||
Create a new signal that can be connected to, disconnected from, and fired.
|
||||
|
||||
Usage:
|
||||
|
||||
local signal = createSignal()
|
||||
local disconnect = signal:connect(function(...)
|
||||
print("fired:", ...)
|
||||
end)
|
||||
|
||||
signal:fire("a", "b", "c")
|
||||
disconnect()
|
||||
|
||||
Avoids mutating listeners list directly to prevent iterator invalidation if
|
||||
a listener is disconnected while the signal is firing.
|
||||
]]
|
||||
local function createSignal()
|
||||
local listeners = {}
|
||||
|
||||
local function connect(newListener)
|
||||
local nextListeners = {}
|
||||
for listener in pairs(listeners) do
|
||||
nextListeners[listener] = true
|
||||
end
|
||||
|
||||
nextListeners[newListener] = true
|
||||
listeners = nextListeners
|
||||
|
||||
return function()
|
||||
local nextListeners = {}
|
||||
for listener in pairs(listeners) do
|
||||
if listener ~= newListener then
|
||||
nextListeners[listener] = true
|
||||
end
|
||||
end
|
||||
|
||||
listeners = nextListeners
|
||||
end
|
||||
end
|
||||
|
||||
local function fire(...)
|
||||
for listener in pairs(listeners) do
|
||||
listener(...)
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
connect = connect,
|
||||
fire = fire,
|
||||
}
|
||||
end
|
||||
|
||||
return createSignal
|
||||
@@ -14,12 +14,20 @@ local Roact = require(script.Parent.Roact)
|
||||
|
||||
local Config = require(script.Config)
|
||||
local App = require(script.Components.App)
|
||||
local Theme = require(script.Components.Theme)
|
||||
local PluginSettings = require(script.Components.PluginSettings)
|
||||
|
||||
local app = Roact.createElement(App, {
|
||||
plugin = plugin,
|
||||
local app = Roact.createElement(Theme.StudioProvider, nil, {
|
||||
Roact.createElement(PluginSettings.StudioProvider, {
|
||||
plugin = plugin,
|
||||
}, {
|
||||
RojoUI = Roact.createElement(App, {
|
||||
plugin = plugin,
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
local tree = Roact.mount(app, game:GetService("CoreGui"), "Rojo UI")
|
||||
local tree = Roact.mount(app, nil, "Rojo UI")
|
||||
|
||||
plugin.Unloading:Connect(function()
|
||||
Roact.unmount(tree)
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
local RbxDom = require(script.Parent.Parent.RbxDom)
|
||||
|
||||
--[[
|
||||
Attempts to set a property on the given instance.
|
||||
]]
|
||||
local function setCanonicalProperty(instance, propertyName, value)
|
||||
local descriptor = RbxDom.findCanonicalPropertyDescriptor(instance.ClassName, propertyName)
|
||||
|
||||
-- We can skip unknown properties; they're not likely reflected to Lua.
|
||||
--
|
||||
-- A good example of a property like this is `Model.ModelInPrimary`, which
|
||||
-- is serialized but not reflected to Lua.
|
||||
if descriptor == nil then
|
||||
return false, "unknown property"
|
||||
end
|
||||
|
||||
if descriptor.scriptability == "None" or descriptor.scriptability == "Read" then
|
||||
return false, "unwritable property"
|
||||
end
|
||||
|
||||
local success, err = descriptor:write(instance, value)
|
||||
|
||||
if not success then
|
||||
-- If we don't have permission to write a property, we just silently
|
||||
-- ignore it.
|
||||
if err.kind == RbxDom.Error.Kind.Roblox and err.extra:find("lacking permission") then
|
||||
return false, "permission error"
|
||||
end
|
||||
|
||||
local message = ("Invalid property %s.%s: %s"):format(descriptor.className, descriptor.name, tostring(err))
|
||||
error(message, 2)
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
return setCanonicalProperty
|
||||
4
plugin/test
Normal file
4
plugin/test
Normal file
@@ -0,0 +1,4 @@
|
||||
#!/bin/sh
|
||||
|
||||
rojo build test-place.project.json -o TestPlace.rbxlx
|
||||
run-in-roblox --script run-tests.server.lua --place TestPlace.rbxlx
|
||||
28
plugin/test-place.project.json
Normal file
28
plugin/test-place.project.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "Rojo Test Place",
|
||||
"tree": {
|
||||
"$className": "DataModel",
|
||||
|
||||
"ReplicatedStorage": {
|
||||
"Rojo": {
|
||||
"$path": "default.project.json"
|
||||
},
|
||||
|
||||
"TestEZ": {
|
||||
"$path": "modules/testez/lib"
|
||||
}
|
||||
},
|
||||
|
||||
"ServerScriptService": {
|
||||
"RunTests": {
|
||||
"$path": "run-tests.server.lua"
|
||||
}
|
||||
},
|
||||
|
||||
"Players": {
|
||||
"$properties": {
|
||||
"CharacterAutoLoads": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"name": "TestEZ",
|
||||
"tree": {
|
||||
"$path": "modules/testez/lib"
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
[package]
|
||||
name = "rojo-test"
|
||||
version = "0.1.0"
|
||||
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
|
||||
edition = "2018"
|
||||
publish = false
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
unstable_glob_ignore_paths = []
|
||||
|
||||
[dependencies]
|
||||
env_logger = "0.6.2"
|
||||
insta = { version = "0.13.1", features = ["redactions"] }
|
||||
log = "0.4.8"
|
||||
paste = "0.1.5"
|
||||
rbx_dom_weak = "1.9.0"
|
||||
reqwest = "0.9.20"
|
||||
serde = "1.0.99"
|
||||
serde_json = "1.0.40"
|
||||
serde_yaml = "0.8.9"
|
||||
tempfile = "3.1.0"
|
||||
walkdir = "2.2.9"
|
||||
|
||||
rojo-insta-ext = { path = "../rojo-insta-ext" }
|
||||
|
||||
# We execute Rojo via std::process::Command, so depend on it so it's built!
|
||||
rojo = { path = ".." }
|
||||
@@ -1,8 +0,0 @@
|
||||
# rojo-test
|
||||
This project does end-to-end testing of Rojo by executing it and checking what side-effects it has.
|
||||
|
||||
rojo-test is meant to be run as a test with:
|
||||
|
||||
```bash
|
||||
cargo test
|
||||
```
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/build_test.rs
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/build_test.rs
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/build_test.rs
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/build_test.rs
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: rojo-test/src/build_test.rs
|
||||
source: tests/tests/build.rs
|
||||
expression: contents
|
||||
---
|
||||
<roblox version="4">
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user