Compare commits
189 Commits
v0.5.0-alp
...
v0.5.0-alp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62bb5a28aa | ||
|
|
f768b200d2 | ||
|
|
a5b6db74e5 | ||
|
|
cbe4467a22 | ||
|
|
8417d18eec | ||
|
|
93ae8187cd | ||
|
|
972338d86a | ||
|
|
a4a6e3b8f6 | ||
|
|
d788dd3042 | ||
|
|
974760f020 | ||
|
|
65569c4a60 | ||
|
|
39da45f0bd | ||
|
|
dc7c27e619 | ||
|
|
90661b7743 | ||
|
|
d07571ea7e | ||
|
|
fbf29e336f | ||
|
|
09a0a803a1 | ||
|
|
dd0327ba85 | ||
|
|
d900887d97 | ||
|
|
2a0efe70a5 | ||
|
|
ce09e57315 | ||
|
|
91023c5239 | ||
|
|
714fb10fac | ||
|
|
aa3e43207f | ||
|
|
e045989d39 | ||
|
|
ad5695210d | ||
|
|
4dab6e5008 | ||
|
|
522f26cf4e | ||
|
|
3eca4bc439 | ||
|
|
b374f67b52 | ||
|
|
c68277be2c | ||
|
|
bb8a3e82e6 | ||
|
|
b511d4ba53 | ||
|
|
fd997d4bda | ||
|
|
21d04a9f85 | ||
|
|
dcb5c12197 | ||
|
|
125e8766c5 | ||
|
|
7bce1f6df4 | ||
|
|
8f66fb6fef | ||
|
|
711e009e6d | ||
|
|
212fe31cb3 | ||
|
|
a3dc4fa001 | ||
|
|
ff53113358 | ||
|
|
94cbe15b54 | ||
|
|
90516e035d | ||
|
|
c77c754f6d | ||
|
|
288c52a2cd | ||
|
|
f0fa7326dd | ||
|
|
f29b0f2f26 | ||
|
|
5dcac24f99 | ||
|
|
1eb11ac377 | ||
|
|
2e89cdcfad | ||
|
|
1b0beccd3d | ||
|
|
abb5a72fc4 | ||
|
|
bf706f7586 | ||
|
|
4459663510 | ||
|
|
68a34dc28b | ||
|
|
ba1826587c | ||
|
|
2e7a8d50b0 | ||
|
|
2a4ca21050 | ||
|
|
0ed6c57c7f | ||
|
|
983d44947e | ||
|
|
5bd88dc82f | ||
|
|
51bbab803f | ||
|
|
a587ba4558 | ||
|
|
075b6cca30 | ||
|
|
4c263bbb3e | ||
|
|
420627d892 | ||
|
|
ce3a409997 | ||
|
|
0f9f1782ae | ||
|
|
d4704a02c5 | ||
|
|
9ca2ed2c93 | ||
|
|
ae12ffdefb | ||
|
|
1e13097126 | ||
|
|
9b8a6b1168 | ||
|
|
8f6dda5cd3 | ||
|
|
91780f236e | ||
|
|
f16474815c | ||
|
|
a8ff6d7e6e | ||
|
|
8395782a2e | ||
|
|
28ea625b01 | ||
|
|
efc569f6ed | ||
|
|
d377e10771 | ||
|
|
fef85877e6 | ||
|
|
19135bfaf4 | ||
|
|
5a147fccc2 | ||
|
|
20976814ba | ||
|
|
27e2612fc9 | ||
|
|
3ea432ef2d | ||
|
|
fe6acbc1e3 | ||
|
|
379b162e64 | ||
|
|
84832955dd | ||
|
|
34b99a51c3 | ||
|
|
fb5245e2af | ||
|
|
ff0a830e0c | ||
|
|
a365f071a4 | ||
|
|
f290e7b5b2 | ||
|
|
83a0ae673c | ||
|
|
7de646c290 | ||
|
|
5d681a72ac | ||
|
|
d725970e6e | ||
|
|
54b82760cd | ||
|
|
77f79fa913 | ||
|
|
6db714a2b1 | ||
|
|
913ac7c9f5 | ||
|
|
eecbfd29e7 | ||
|
|
41025225b2 | ||
|
|
07c7b28c03 | ||
|
|
3faf3d2a56 | ||
|
|
be094d5b7c | ||
|
|
459673bd59 | ||
|
|
2968b70e6b | ||
|
|
b6989a18fc | ||
|
|
4d6a504836 | ||
|
|
6c3737df68 | ||
|
|
9f382ed9bd | ||
|
|
f9e86e58d6 | ||
|
|
469f9c927f | ||
|
|
312724189b | ||
|
|
ec0a1f1ce4 | ||
|
|
ad93631ef8 | ||
|
|
3b6238ff93 | ||
|
|
5b9facee00 | ||
|
|
376f2a554a | ||
|
|
5fd0bd3db9 | ||
|
|
2deb3bbf23 | ||
|
|
01bef0c2b8 | ||
|
|
b65a8ce680 | ||
|
|
5fc4f63238 | ||
|
|
9b0e0c175b | ||
|
|
eb97e925e6 | ||
|
|
16f8975b18 | ||
|
|
5073fce2f7 | ||
|
|
cf5036eec6 | ||
|
|
20be37dd8b | ||
|
|
93349ae2dc | ||
|
|
be81de74cd | ||
|
|
88e739090d | ||
|
|
7f324f1957 | ||
|
|
4f31c9e72f | ||
|
|
c9a663ed39 | ||
|
|
105d8aeb6b | ||
|
|
6ea1211bc5 | ||
|
|
c13291a598 | ||
|
|
aaa78c618c | ||
|
|
2890c677d4 | ||
|
|
51a010de00 | ||
|
|
ca0aabd814 | ||
|
|
91d1ba1910 | ||
|
|
c7c739dc00 | ||
|
|
7a8389bf11 | ||
|
|
5f062b8ea3 | ||
|
|
b9ee14a0f9 | ||
|
|
c3baf73455 | ||
|
|
4a597e0ba7 | ||
|
|
d5f3e25bea | ||
|
|
5e4c1a8359 | ||
|
|
d86e655ad2 | ||
|
|
80154bbf9f | ||
|
|
be853ba2a7 | ||
|
|
4d3036d030 | ||
|
|
ecb9b5e28f | ||
|
|
38e3c198f2 | ||
|
|
2f64501556 | ||
|
|
2c2554d73d | ||
|
|
69d1accf3f | ||
|
|
785bdb8ecb | ||
|
|
78a1947cec | ||
|
|
0ff59ecb4e | ||
|
|
b58fed16b4 | ||
|
|
6719be02c3 | ||
|
|
8757834e07 | ||
|
|
aa243d1b8a | ||
|
|
aeb18eb124 | ||
|
|
6c3e118ee3 | ||
|
|
3c0fe4d684 | ||
|
|
12fd9aa1ef | ||
|
|
821122a33d | ||
|
|
0d9406d991 | ||
|
|
350eec3bc7 | ||
|
|
e700b3105a | ||
|
|
dd2a730b4a | ||
|
|
c6766bbe77 | ||
|
|
e5d3204b6c | ||
|
|
4767cbd12b | ||
|
|
deb4118c5d | ||
|
|
4516df5aac | ||
|
|
663df7bdc2 | ||
|
|
e81f0a4a95 |
2
.cargo/config
Normal file
@@ -0,0 +1,2 @@
|
||||
[target.x86_64-pc-windows-msvc]
|
||||
rustflags = ["-Ctarget-feature=+crt-static"]
|
||||
9
.gitignore
vendored
@@ -1,5 +1,8 @@
|
||||
/site
|
||||
/target
|
||||
/server/scratch
|
||||
/scratch-project
|
||||
/server/failed-snapshots/
|
||||
**/*.rs.bk
|
||||
/generate-docs.run
|
||||
/*.rbxm
|
||||
/*.rbxmx
|
||||
/*.rbxl
|
||||
/*.rbxlx
|
||||
12
.gitmodules
vendored
@@ -1,12 +1,6 @@
|
||||
[submodule "plugin/modules/roact"]
|
||||
path = plugin/modules/roact
|
||||
url = https://github.com/Roblox/roact.git
|
||||
[submodule "plugin/modules/rodux"]
|
||||
path = plugin/modules/rodux
|
||||
url = https://github.com/Roblox/rodux.git
|
||||
[submodule "plugin/modules/roact-rodux"]
|
||||
path = plugin/modules/roact-rodux
|
||||
url = https://github.com/Roblox/roact-rodux.git
|
||||
[submodule "plugin/modules/testez"]
|
||||
path = plugin/modules/testez
|
||||
url = https://github.com/Roblox/testez.git
|
||||
@@ -16,3 +10,9 @@
|
||||
[submodule "plugin/modules/promise"]
|
||||
path = plugin/modules/promise
|
||||
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/LPGhatguy/rbx-dom
|
||||
|
||||
60
.travis.yml
@@ -1,38 +1,54 @@
|
||||
matrix:
|
||||
include:
|
||||
- language: python
|
||||
env:
|
||||
- LUA="lua=5.1"
|
||||
# Lua tests are currently disabled because of holes in Lemur that are pretty
|
||||
# tedious to fix. It should be fixed by either adding missing features to
|
||||
# Lemur or by migrating to a CI system based on real Roblox instead.
|
||||
|
||||
before_install:
|
||||
- pip install hererocks
|
||||
- hererocks lua_install -r^ --$LUA
|
||||
- export PATH=$PATH:$PWD/lua_install/bin
|
||||
# - language: python
|
||||
# env:
|
||||
# - LUA="lua=5.1"
|
||||
|
||||
install:
|
||||
- luarocks install luafilesystem
|
||||
- luarocks install busted
|
||||
- luarocks install luacov
|
||||
- luarocks install luacov-coveralls
|
||||
- luarocks install luacheck
|
||||
# before_install:
|
||||
# - pip install hererocks
|
||||
# - hererocks lua_install -r^ --$LUA
|
||||
# - export PATH=$PATH:$PWD/lua_install/bin
|
||||
|
||||
script:
|
||||
- cd plugin
|
||||
- luacheck src
|
||||
- lua -lluacov spec.lua
|
||||
# install:
|
||||
# - luarocks install luafilesystem
|
||||
# - luarocks install busted
|
||||
# - luarocks install luacov
|
||||
# - luarocks install luacov-coveralls
|
||||
# - luarocks install luacheck
|
||||
|
||||
after_success:
|
||||
- cd plugin
|
||||
- luacov-coveralls -e $TRAVIS_BUILD_DIR/lua_install
|
||||
# script:
|
||||
# - cd plugin
|
||||
# - luacheck src
|
||||
# - lua -lluacov spec.lua
|
||||
|
||||
# after_success:
|
||||
# - cd plugin
|
||||
# - luacov-coveralls -e $TRAVIS_BUILD_DIR/lua_install
|
||||
|
||||
- language: rust
|
||||
rust: stable
|
||||
rust: 1.34.0
|
||||
cache: cargo
|
||||
|
||||
script:
|
||||
- cargo test --verbose
|
||||
- cargo test --verbose --all-features
|
||||
|
||||
- language: rust
|
||||
rust: stable
|
||||
cache: cargo
|
||||
|
||||
script:
|
||||
- cargo test --verbose
|
||||
- cargo test --verbose --all-features
|
||||
|
||||
- language: rust
|
||||
rust: beta
|
||||
cache: cargo
|
||||
|
||||
script:
|
||||
- cargo test --verbose
|
||||
- cargo test --verbose
|
||||
- cargo test --verbose --all-features
|
||||
179
CHANGELOG.md
@@ -1,8 +1,111 @@
|
||||
# Rojo Changelog
|
||||
|
||||
## [Unreleased]
|
||||
## Unreleased Changes
|
||||
|
||||
## [0.5.0 Alpha 1](https://github.com/LPGhatguy/rojo/releases/tag/v0.5.0-alpha.1) (January 14, 2019)
|
||||
## [0.5.0 Alpha 13](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.0-alpha.13) (August 2, 2019)
|
||||
* Bumped minimum Rust version to 1.34.0.
|
||||
* Fixed default port documentation in `rojo serve --help` ([#219](https://github.com/rojo-rbx/rojo/issues/219))
|
||||
* Fixed BrickColor support by upgrading Roblox-related dependencies
|
||||
|
||||
## [0.5.0 Alpha 12](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.0-alpha.12) (July 2, 2019)
|
||||
* Added `.meta.json` files
|
||||
* `init.meta.json` files replace `init.model.json` files from Rojo 0.4.x ([#183](https://github.com/rojo-rbx/rojo/pull/183))
|
||||
* Other `.meta.json` files allow attaching extra data to other files ([#189](https://github.com/rojo-rbx/rojo/pull/189))
|
||||
* Added support for infinite and NaN values in types like `Vector2` when building models and places.
|
||||
* These types aren't supported for live-syncing yet due to limitations around JSON encoding.
|
||||
* Added support for using `SharedString` values when building XML models and places.
|
||||
* Added support for live-syncing `CollectionService` tags.
|
||||
* Added a warning when building binary place files, since they're still experimental and have bugs.
|
||||
* Added a warning when trying to use Rojo 0.5.x with a Rojo 0.4.x-only project.
|
||||
* Added a warning when a Rojo project contains keys that start with `$`, which are reserved names. ([#191](https://github.com/rojo-rbx/rojo/issues/191))
|
||||
* Rojo now throws an error if unknown keys are found most files.
|
||||
* Added an icon to the plugin's toolbar button
|
||||
* Changed the plugin to use a docking widget for all UI.
|
||||
* Changed the plugin to ignore unknown properties when live-syncing.
|
||||
* Rojo's approach to this problem might change later, like with a strict model mode ([#190](https://github.com/rojo-rbx/rojo/issues/190)) or another approach.
|
||||
* Upgraded to reflection database from client release 388.
|
||||
* Updated Rojo's branding to shift the color palette to make it work better on dark backgrounds
|
||||
|
||||
## [0.5.0 Alpha 11](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.0-alpha.11) (May 29, 2019)
|
||||
* Added support for implicit property values in JSON model files ([#154](https://github.com/rojo-rbx/rojo/pull/154))
|
||||
* `Content` propertyes can now be specified in projects and model files as regular string literals.
|
||||
* Added support for `BrickColor` properties.
|
||||
* Added support for properties added in client release 384, like `Lighting.Technology` being set to `"ShadowMap"`.
|
||||
* Improved performance when working with XML models and places
|
||||
* Fixed serializing empty `Content` properties as XML
|
||||
* Fixed serializing infinite and NaN floating point properties in XML
|
||||
* Improved compatibility with XML models
|
||||
* Plugin should now be able to live-sync more properties, and ignore ones it can't, like `Lighting.Technology`.
|
||||
|
||||
## 0.5.0 Alpha 10
|
||||
* This release was a dud due to [issue #176](https://github.com/rojo-rbx/rojo/issues/176) and was rolled back.
|
||||
|
||||
## [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
|
||||
* 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.
|
||||
|
||||
## [0.5.0 Alpha 8](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.0-alpha.8) (March 29, 2019)
|
||||
* Added support for a bunch of new types when dealing with XML model/place files:
|
||||
* `ColorSequence`
|
||||
* `Float64`
|
||||
* `Int64`
|
||||
* `NumberRange`
|
||||
* `NumberSequence`
|
||||
* `PhysicalProperties`
|
||||
* `Ray`
|
||||
* `Rect`
|
||||
* `Ref`
|
||||
* Improved server instance ordering behavior when files are added during a live session ([#135](https://github.com/rojo-rbx/rojo/pull/135))
|
||||
* Fixed error being thrown when trying to unload the Rojo plugin.
|
||||
* Added partial fix for [issue #141](https://github.com/rojo-rbx/rojo/issues/141) for `Lighting.Technology`, which should restore live sync functionality for the default project file.
|
||||
|
||||
## [0.5.0 Alpha 6](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.0-alpha.6) (March 19, 2019)
|
||||
* Fixed `rojo init` giving unexpected results by upgrading to `rbx_dom_weak` 1.1.0
|
||||
* Fixed live server not responding when the Rojo plugin is connected ([#133](https://github.com/rojo-rbx/rojo/issues/133))
|
||||
* Updated default place file:
|
||||
* Improved default properties to be closer to Studio's built-in 'Baseplate' template
|
||||
* Added a baseplate to the project file (Thanks, [@AmaranthineCodices](https://github.com/AmaranthineCodices/)!)
|
||||
* Added more type support to Rojo plugin
|
||||
* Fixed some cases where the Rojo plugin would leave around objects that it knows should be deleted
|
||||
* Updated plugin to correctly listen to `Plugin.Unloading` when installing or uninstalling new plugins
|
||||
|
||||
## [0.5.0 Alpha 5](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.0-alpha.5) (March 1, 2019)
|
||||
* Upgraded core dependencies, which improves compatibility for lots of instance types
|
||||
* Upgraded from `rbx_tree` 0.2.0 to `rbx_dom_weak` 1.0.0
|
||||
* Upgraded from `rbx_xml` 0.2.0 to `rbx_xml` 0.4.0
|
||||
* Upgraded from `rbx_binary` 0.2.0 to `rbx_binary` 0.4.0
|
||||
* Added support for non-primitive types in the Rojo plugin.
|
||||
* Types like `Color3` and `CFrame` can now be updated live!
|
||||
* Fixed plugin assets flashing in on first load ([#121](https://github.com/rojo-rbx/rojo/issues/121))
|
||||
* Changed Rojo's HTTP server from Rouille to Hyper, which reduced the release size by around a megabyte.
|
||||
* Added property type inference to projects, which makes specifying services a lot easier ([#130](https://github.com/rojo-rbx/rojo/pull/130))
|
||||
* Made error messages from invalid and missing files more user-friendly
|
||||
|
||||
## [0.5.0 Alpha 4](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.0-alpha.4) (February 8, 2019)
|
||||
* Added support for nested partitions ([#102](https://github.com/rojo-rbx/rojo/issues/102))
|
||||
* Added support for 'transmuting' partitions ([#112](https://github.com/rojo-rbx/rojo/issues/112))
|
||||
* Added support for aliasing filesystem paths ([#105](https://github.com/rojo-rbx/rojo/issues/105))
|
||||
* Changed Windows builds to statically link the CRT ([#89](https://github.com/rojo-rbx/rojo/issues/89))
|
||||
|
||||
## [0.5.0 Alpha 3](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.0-alpha.3) (February 1, 2019)
|
||||
* Changed default project file name from `roblox-project.json` to `default.project.json` ([#120](https://github.com/rojo-rbx/rojo/pull/120))
|
||||
* The old file name will still be supported until 0.5.0 is fully released.
|
||||
* Added warning when loading project files that don't end in `.project.json`
|
||||
* This new extension enables Rojo to distinguish project files from random JSON files, which is necessary to support nested projects.
|
||||
* Added new (empty) diagnostic page served from the server
|
||||
* Added better error messages for when a file is missing that's referenced by a Rojo project
|
||||
* Added support for visualization endpoints returning GraphViz source when Dot is not available
|
||||
* Fixed an in-memory filesystem regression introduced recently ([#119](https://github.com/rojo-rbx/rojo/pull/119))
|
||||
|
||||
## [0.5.0 Alpha 2](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.0-alpha.2) (January 28, 2019)
|
||||
* Added support for `.model.json` files, compatible with 0.4.x
|
||||
* Fixed in-memory filesystem not handling out-of-order filesystem change events
|
||||
* Fixed long-polling error caused by a promise mixup ([#110](https://github.com/rojo-rbx/rojo/issues/110))
|
||||
|
||||
## [0.5.0 Alpha 1](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.0-alpha.1) (January 25, 2019)
|
||||
* Changed plugin UI to be way prettier
|
||||
* Thanks to [Reselim](https://github.com/Reselim) for the design!
|
||||
* Changed plugin error messages to be a little more useful
|
||||
@@ -10,7 +113,7 @@
|
||||
* Fixed bug where bad server responses could cause the plugin to be in a bad state
|
||||
* Upgraded to rbx\_tree, rbx\_xml, and rbx\_binary 0.2.0, which dramatically expands the kinds of properties that Rojo can handle, especially in XML.
|
||||
|
||||
## [0.5.0 Alpha 0](https://github.com/LPGhatguy/rojo/releases/tag/v0.5.0-alpha.0) (January 14, 2019)
|
||||
## [0.5.0 Alpha 0](https://github.com/rojo-rbx/rojo/releases/tag/v0.5.0-alpha.0) (January 14, 2019)
|
||||
* "Epiphany" rewrite, in progress since the beginning of time
|
||||
* New live sync protocol
|
||||
* Uses HTTP long polling to reduce request count and improve responsiveness
|
||||
@@ -35,105 +138,105 @@
|
||||
* Multiple places can be specified, like when building a multi-place game
|
||||
* Added support for specifying properties on services in project files
|
||||
|
||||
## [0.4.13](https://github.com/LPGhatguy/rojo/releases/tag/v0.4.13) (November 12, 2018)
|
||||
## [0.4.13](https://github.com/rojo-rbx/rojo/releases/tag/v0.4.13) (November 12, 2018)
|
||||
* When `rojo.json` points to a file or directory that does not exist, Rojo now issues a warning instead of throwing an error and exiting
|
||||
|
||||
## [0.4.12](https://github.com/LPGhatguy/rojo/releases/tag/v0.4.12) (June 21, 2018)
|
||||
* Fixed obscure assertion failure when renaming or deleting files ([#78](https://github.com/LPGhatguy/rojo/issues/78))
|
||||
* Added a `PluginAction` for the sync in command, which should help with some automation scripts ([#80](https://github.com/LPGhatguy/rojo/pull/80))
|
||||
## [0.4.12](https://github.com/rojo-rbx/rojo/releases/tag/v0.4.12) (June 21, 2018)
|
||||
* Fixed obscure assertion failure when renaming or deleting files ([#78](https://github.com/rojo-rbx/rojo/issues/78))
|
||||
* Added a `PluginAction` for the sync in command, which should help with some automation scripts ([#80](https://github.com/rojo-rbx/rojo/pull/80))
|
||||
|
||||
## [0.4.11](https://github.com/LPGhatguy/rojo/releases/tag/v0.4.11) (June 10, 2018)
|
||||
## [0.4.11](https://github.com/rojo-rbx/rojo/releases/tag/v0.4.11) (June 10, 2018)
|
||||
* Defensively insert existing instances into RouteMap; should fix most duplication cases when syncing into existing trees.
|
||||
* Fixed incorrect synchronization from `Plugin:_pull` that would cause polling to create issues
|
||||
* Fixed incorrect file routes being assigned to `init.lua` and `init.model.json` files
|
||||
* Untangled route handling-internals slightly
|
||||
|
||||
## [0.4.10](https://github.com/LPGhatguy/rojo/releases/tag/v0.4.10) (June 2, 2018)
|
||||
* Added support for `init.model.json` files, which enable versioning `Tool` instances (among other things) with Rojo. ([#66](https://github.com/LPGhatguy/rojo/issues/66))
|
||||
## [0.4.10](https://github.com/rojo-rbx/rojo/releases/tag/v0.4.10) (June 2, 2018)
|
||||
* Added support for `init.model.json` files, which enable versioning `Tool` instances (among other things) with Rojo. ([#66](https://github.com/rojo-rbx/rojo/issues/66))
|
||||
* Fixed obscure error when syncing into an invalid service.
|
||||
* Fixed multiple sync processes occurring when a server ID mismatch is detected.
|
||||
|
||||
## [0.4.9](https://github.com/LPGhatguy/rojo/releases/tag/v0.4.9) (May 26, 2018)
|
||||
* Fixed warning when renaming or removing files that would sometimes corrupt the instance cache ([#72](https://github.com/LPGhatguy/rojo/pull/72))
|
||||
## [0.4.9](https://github.com/rojo-rbx/rojo/releases/tag/v0.4.9) (May 26, 2018)
|
||||
* Fixed warning when renaming or removing files that would sometimes corrupt the instance cache ([#72](https://github.com/rojo-rbx/rojo/pull/72))
|
||||
* JSON models are no longer as strict -- `Children` and `Properties` are now optional.
|
||||
|
||||
## [0.4.8](https://github.com/LPGhatguy/rojo/releases/tag/v0.4.8) (May 26, 2018)
|
||||
## [0.4.8](https://github.com/rojo-rbx/rojo/releases/tag/v0.4.8) (May 26, 2018)
|
||||
* Hotfix to prevent errors from being thrown when objects managed by Rojo are deleted
|
||||
|
||||
## [0.4.7](https://github.com/LPGhatguy/rojo/releases/tag/v0.4.7) (May 25, 2018)
|
||||
* Added icons to the Rojo plugin, made by [@Vorlias](https://github.com/Vorlias)! ([#70](https://github.com/LPGhatguy/rojo/pull/70))
|
||||
* Server will now issue a warning if no partitions are specified in `rojo serve` ([#40](https://github.com/LPGhatguy/rojo/issues/40))
|
||||
## [0.4.7](https://github.com/rojo-rbx/rojo/releases/tag/v0.4.7) (May 25, 2018)
|
||||
* Added icons to the Rojo plugin, made by [@Vorlias](https://github.com/Vorlias)! ([#70](https://github.com/rojo-rbx/rojo/pull/70))
|
||||
* Server will now issue a warning if no partitions are specified in `rojo serve` ([#40](https://github.com/rojo-rbx/rojo/issues/40))
|
||||
|
||||
## [0.4.6](https://github.com/LPGhatguy/rojo/releases/tag/v0.4.6) (May 21, 2018)
|
||||
* Rojo handles being restarted by Roblox Studio more gracefully ([#67](https://github.com/LPGhatguy/rojo/issues/67))
|
||||
## [0.4.6](https://github.com/rojo-rbx/rojo/releases/tag/v0.4.6) (May 21, 2018)
|
||||
* Rojo handles being restarted by Roblox Studio more gracefully ([#67](https://github.com/rojo-rbx/rojo/issues/67))
|
||||
* Folders should no longer get collapsed when syncing occurs.
|
||||
* **Significant** robustness improvements with regards to caching.
|
||||
* **This should catch all existing script duplication bugs.**
|
||||
* If there are any bugs with script duplication or caching in the future, restarting the Rojo server process will fix them for that session.
|
||||
* Fixed message in plugin not being prefixed with `Rojo: `.
|
||||
|
||||
## [0.4.5](https://github.com/LPGhatguy/rojo/releases/tag/v0.4.5) (May 1, 2018)
|
||||
## [0.4.5](https://github.com/rojo-rbx/rojo/releases/tag/v0.4.5) (May 1, 2018)
|
||||
* Rojo messages are now prefixed with `Rojo: ` to make them stand out in the output more.
|
||||
* Fixed server to notice file changes *much* more quickly. (200ms vs 1000ms)
|
||||
* Server now lists name of project when starting up.
|
||||
* Rojo now throws an error if no project file is found. ([#63](https://github.com/LPGhatguy/rojo/issues/63))
|
||||
* Fixed multiple sync operations occuring at the same time. ([#61](https://github.com/LPGhatguy/rojo/issues/61))
|
||||
* Partitions targeting files directly now work as expected. ([#57](https://github.com/LPGhatguy/rojo/issues/57))
|
||||
* Rojo now throws an error if no project file is found. ([#63](https://github.com/rojo-rbx/rojo/issues/63))
|
||||
* Fixed multiple sync operations occuring at the same time. ([#61](https://github.com/rojo-rbx/rojo/issues/61))
|
||||
* Partitions targeting files directly now work as expected. ([#57](https://github.com/rojo-rbx/rojo/issues/57))
|
||||
|
||||
## [0.4.4](https://github.com/LPGhatguy/rojo/releases/tag/v0.4.4) (April 7, 2018)
|
||||
## [0.4.4](https://github.com/rojo-rbx/rojo/releases/tag/v0.4.4) (April 7, 2018)
|
||||
* Fix small regression introduced in 0.4.3
|
||||
|
||||
## [0.4.3](https://github.com/LPGhatguy/rojo/releases/tag/v0.4.3) (April 7, 2018)
|
||||
* Plugin now automatically selects `HttpService` if it determines that HTTP isn't enabled ([#58](https://github.com/LPGhatguy/rojo/pull/58))
|
||||
## [0.4.3](https://github.com/rojo-rbx/rojo/releases/tag/v0.4.3) (April 7, 2018)
|
||||
* Plugin now automatically selects `HttpService` if it determines that HTTP isn't enabled ([#58](https://github.com/rojo-rbx/rojo/pull/58))
|
||||
* Plugin now has much more robust handling and will wipe all state when the server changes.
|
||||
* This should fix issues that would otherwise be solved by restarting Roblox Studio.
|
||||
|
||||
## [0.4.2](https://github.com/LPGhatguy/rojo/releases/tag/v0.4.2) (April 4, 2018)
|
||||
## [0.4.2](https://github.com/rojo-rbx/rojo/releases/tag/v0.4.2) (April 4, 2018)
|
||||
* Fixed final case of duplicated instance insertion, caused by reconciled instances not being inserted into `RouteMap`.
|
||||
* The reconciler is still not a perfect solution, especially if script instances get moved around without being destroyed. I don't think this can be fixed before a big refactor.
|
||||
|
||||
## [0.4.1](https://github.com/LPGhatguy/rojo/releases/tag/v0.4.1) (April 1, 2018)
|
||||
## [0.4.1](https://github.com/rojo-rbx/rojo/releases/tag/v0.4.1) (April 1, 2018)
|
||||
* Merged plugin repository into main Rojo repository for easier tracking.
|
||||
* Improved `RouteMap` object tracking; this should fix some cases of duplicated instances being synced into the tree.
|
||||
|
||||
## [0.4.0](https://github.com/LPGhatguy/rojo/releases/tag/v0.4.0) (March 27, 2018)
|
||||
## [0.4.0](https://github.com/rojo-rbx/rojo/releases/tag/v0.4.0) (March 27, 2018)
|
||||
* Protocol version 1, which shifts more responsibility onto the server
|
||||
* This is a **major breaking** change!
|
||||
* The server now has a content of 'filter plugins', which transform data at various stages in the pipeline
|
||||
* The server now exposes Roblox instance objects instead of file contents, which lines up with how `rojo pack` will work, and paves the way for more robust syncing.
|
||||
* Added `*.model.json` files, which let you embed small Roblox objects into your Rojo tree.
|
||||
* Improved error messages in some cases ([#46](https://github.com/LPGhatguy/rojo/issues/46))
|
||||
* Improved error messages in some cases ([#46](https://github.com/rojo-rbx/rojo/issues/46))
|
||||
|
||||
## [0.3.2](https://github.com/LPGhatguy/rojo/releases/tag/v0.3.2) (December 20, 2017)
|
||||
## [0.3.2](https://github.com/rojo-rbx/rojo/releases/tag/v0.3.2) (December 20, 2017)
|
||||
* Fixed `rojo serve` failing to correctly construct an absolute root path when passed as an argument
|
||||
* Fixed intense CPU usage when running `rojo serve`
|
||||
|
||||
## [0.3.1](https://github.com/LPGhatguy/rojo/releases/tag/v0.3.1) (December 14, 2017)
|
||||
## [0.3.1](https://github.com/rojo-rbx/rojo/releases/tag/v0.3.1) (December 14, 2017)
|
||||
* Improved error reporting when invalid JSON is found in a `rojo.json` project
|
||||
* These messages are passed on from Serde
|
||||
|
||||
## [0.3.0](https://github.com/LPGhatguy/rojo/releases/tag/v0.3.0) (December 12, 2017)
|
||||
## [0.3.0](https://github.com/rojo-rbx/rojo/releases/tag/v0.3.0) (December 12, 2017)
|
||||
* Factored out the plugin into a separate repository
|
||||
* Fixed server when using a file as a partition
|
||||
* Previously, trailing slashes were put on the end of a partition even if the read request was an empty string. This broke file reading on Windows when a partition pointed to a file instead of a directory!
|
||||
* Started running automatic tests on Travis CI (#9)
|
||||
|
||||
## [0.2.3](https://github.com/LPGhatguy/rojo/releases/tag/v0.2.3) (December 4, 2017)
|
||||
## [0.2.3](https://github.com/rojo-rbx/rojo/releases/tag/v0.2.3) (December 4, 2017)
|
||||
* Plugin only release
|
||||
* Tightened `init` file rules to only match script files
|
||||
* Previously, Rojo would sometimes pick up the wrong file when syncing
|
||||
|
||||
## [0.2.2](https://github.com/LPGhatguy/rojo/releases/tag/v0.2.2) (December 1, 2017)
|
||||
## [0.2.2](https://github.com/rojo-rbx/rojo/releases/tag/v0.2.2) (December 1, 2017)
|
||||
* Plugin only release
|
||||
* Fixed broken reconciliation behavior with `init` files
|
||||
|
||||
## [0.2.1](https://github.com/LPGhatguy/rojo/releases/tag/v0.2.1) (December 1, 2017)
|
||||
## [0.2.1](https://github.com/rojo-rbx/rojo/releases/tag/v0.2.1) (December 1, 2017)
|
||||
* Plugin only release
|
||||
* Changes default port to 8000
|
||||
|
||||
## [0.2.0](https://github.com/LPGhatguy/rojo/releases/tag/v0.2.0) (December 1, 2017)
|
||||
## [0.2.0](https://github.com/rojo-rbx/rojo/releases/tag/v0.2.0) (December 1, 2017)
|
||||
* Support for `init.lua` like rbxfs and rbxpacker
|
||||
* More robust syncing with a new reconciler
|
||||
|
||||
## [0.1.0](https://github.com/LPGhatguy/rojo/releases/tag/v0.1.0) (November 29, 2017)
|
||||
* Initial release, functionally very similar to [rbxfs](https://github.com/LPGhatguy/rbxfs)
|
||||
## [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)
|
||||
1757
Cargo.lock
generated
@@ -1,5 +1,7 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"server",
|
||||
"rojo-e2e",
|
||||
]
|
||||
]
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
58
README.md
@@ -1,68 +1,56 @@
|
||||
<div align="center">
|
||||
<img src="assets/rojo-logo.png" alt="Rojo" height="217" />
|
||||
<a href="https://rojo.space">
|
||||
<img src="assets/logo-512.png" alt="Rojo" height="217" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div> </div>
|
||||
|
||||
<div align="center">
|
||||
<a href="https://travis-ci.org/LPGhatguy/rojo">
|
||||
<img src="https://api.travis-ci.org/LPGhatguy/rojo.svg?branch=master" alt="Travis-CI Build Status" />
|
||||
<a href="https://travis-ci.org/rojo-rbx/rojo">
|
||||
<img src="https://api.travis-ci.org/rojo-rbx/rojo.svg?branch=master" alt="Travis-CI Build Status" />
|
||||
</a>
|
||||
<a href="https://crates.io/crates/rojo">
|
||||
<img src="https://img.shields.io/crates/v/rojo.svg?label=version" alt="Latest server version" />
|
||||
</a>
|
||||
<a href="https://lpghatguy.github.io/rojo/0.4.x">
|
||||
<img src="https://img.shields.io/badge/docs-0.4.x-brightgreen.svg" alt="Rojo Documentation" />
|
||||
<a href="https://rojo.space/docs/0.4.x">
|
||||
<img src="https://img.shields.io/badge/docs-0.4.x-brightgreen.svg" alt="Rojo 0.4.x Documentation" />
|
||||
</a>
|
||||
<a href="https://lpghatguy.github.io/rojo/0.5.x">
|
||||
<img src="https://img.shields.io/badge/docs-0.5.x-brightgreen.svg" alt="Rojo Documentation" />
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
**Rojo** is a flexible multi-tool designed for creating robust Roblox projects.
|
||||
**Rojo** is a tool designed to enable Roblox developers to use professional-grade software engineering tools.
|
||||
|
||||
It lets Roblox developers use industry-leading tools like Git and VS Code, and crucial utilities like Luacheck.
|
||||
With Rojo, it's possible to use industry-leading tools like **Visual Studio Code** and **Git**.
|
||||
|
||||
Rojo is designed for **power users** who want to use the **best tools available** for building games, libraries, and plugins.
|
||||
Rojo is designed for power users who want to use the best tools available for building games, libraries, and plugins.
|
||||
|
||||
## Features
|
||||
Rojo lets you:
|
||||
Rojo enables:
|
||||
|
||||
* Work on scripts from the filesystem, in your favorite editor
|
||||
* Version your place, model, or plugin using Git or another VCS
|
||||
* Sync `rbxmx` and `rbxm` models into your game in real time
|
||||
* Package and deploy your project to Roblox.com from the command line
|
||||
* Working on scripts and models from the filesystem, in your favorite editor
|
||||
* Versioning your game, library, or plugin using Git or another VCS
|
||||
* 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:
|
||||
|
||||
* Automatically convert your existing game to work with Rojo
|
||||
* Sync instances from Roblox Studio to the filesystem
|
||||
* Compile MoonScript and other custom things for your project
|
||||
* Automatically manage your assets on Roblox.com, like images and sounds
|
||||
* Import custom instances like MoonScript code
|
||||
|
||||
## [Documentation](https://lpghatguy.github.io/rojo/0.4.x)
|
||||
You can also view the documentation by browsing the [docs](https://github.com/LPGhatguy/rojo/tree/master/docs) folder of the repository, but because it uses a number of Markdown extensions, it may not be very readable.
|
||||
|
||||
## Inspiration and Alternatives
|
||||
There are lots of other tools that sync scripts into Roblox or provide other tools for working with Roblox places.
|
||||
|
||||
Here are a few, if you're looking for alternatives or supplements to Rojo:
|
||||
|
||||
* [rbxmk by Anaminus](https://github.com/anaminus/rbxmk)
|
||||
* [Rofresh by Osyris](https://github.com/osyrisrblx/rofresh)
|
||||
* [RbxRefresh by Osyris](https://github.com/osyrisrblx/RbxRefresh)
|
||||
* [Studio Bridge by Vocksel](https://github.com/vocksel/studio-bridge)
|
||||
* [Elixir by Vocksel](https://github.com/vocksel/elixir)
|
||||
* [RbxSync by evaera](https://github.com/evaera/RbxSync)
|
||||
* [CodeSync by MemoryPenguin](https://github.com/MemoryPenguin/CodeSync)
|
||||
* [rbx-exteditor by MemoryPenguin](https://github.com/MemoryPenguin/rbx-exteditor)
|
||||
|
||||
If you use a plugin that _isn't_ Rojo for syncing code, open an issue and let me know why! I'd like Rojo to be the end-all tool so that people stop reinventing solutions to this problem.
|
||||
## [Documentation](https://rojo.space/docs/latest)
|
||||
If you find any mistakes, feel free to make changes in the [docs](https://github.com/rojo-rbx/rojo/tree/master/docs) folder of this repository and submit a pull request!
|
||||
|
||||
## Contributing
|
||||
Pull requests are welcome!
|
||||
|
||||
All pull requests are run against a test suite on Travis CI. That test suite should always pass!
|
||||
Rojo supports Rust 1.34.0 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.
|
||||
BIN
assets/logo-512.png
Normal file
|
After Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 375 B |
|
Before Width: | Height: | Size: 382 B |
|
Before Width: | Height: | Size: 430 B |
21
bin/test-scratch-project
Normal file
@@ -0,0 +1,21 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Copies a project from 'test-projects' into a folder that can be messed with
|
||||
# without accidentally checking the results into version control.
|
||||
|
||||
set -e
|
||||
|
||||
if [ ! -d "test-projects/$1" ]
|
||||
then
|
||||
echo "Pick a project that exists!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -d "scratch-project/$1" ]
|
||||
then
|
||||
rm -rf "scratch-project/$1"
|
||||
fi
|
||||
|
||||
mkdir -p scratch-project
|
||||
cp -r "test-projects/$1" scratch-project
|
||||
cargo run -- serve "scratch-project/$1"
|
||||
39
design.gv
@@ -1,39 +0,0 @@
|
||||
digraph G {
|
||||
graph [
|
||||
ranksep = "0.7",
|
||||
nodesep = "1.0",
|
||||
];
|
||||
node [
|
||||
fontname = "Hack",
|
||||
shape = "record",
|
||||
];
|
||||
|
||||
roblox_studio -> plugin [dir = "both"];
|
||||
plugin -> web_server [style = "dashed", dir = "both"];
|
||||
|
||||
web_server -> session;
|
||||
|
||||
session -> rbx_session;
|
||||
session -> fs_watcher;
|
||||
session -> message_queue;
|
||||
|
||||
fs_watcher -> imfs [weight = "10"];
|
||||
fs_watcher -> rbx_session [constraint = "false"];
|
||||
|
||||
imfs -> fs;
|
||||
|
||||
rbx_session -> imfs;
|
||||
rbx_session -> middlewares [weight = "10"];
|
||||
rbx_session -> message_queue [constraint = "false"];
|
||||
|
||||
plugin [label = "Studio Plugin"];
|
||||
roblox_studio [label = "Roblox Studio"];
|
||||
fs [label = "Filesystem"];
|
||||
fs_watcher [label = "Filesystem Watcher"];
|
||||
session [label = "Session"];
|
||||
web_server [label = "Web API"];
|
||||
imfs [label = "In-Memory Filesystem"];
|
||||
rbx_session [label = "RbxSession"];
|
||||
message_queue [label = "MessageQueue"];
|
||||
middlewares [label = "Middlewares"];
|
||||
}
|
||||
13
docs/extra.css
Normal file
@@ -0,0 +1,13 @@
|
||||
.md-typeset__table {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.feature-image img {
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.codehilite {
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
To use Rojo to build a place, you'll need to create a new project file, which tells Rojo how your project is structured on-disk and in Roblox.
|
||||
|
||||
Create a new folder, then run `rojo init` inside that folder to initialize an empty project.
|
||||
|
||||
```sh
|
||||
mkdir my-new-project
|
||||
cd my-new-project
|
||||
|
||||
rojo init
|
||||
```
|
||||
|
||||
Rojo will make a small project file in your directory, named `roblox-project.json`. It'll make sure that any code in the directory `src` will get put into `ReplicatedStorage.Source`.
|
||||
|
||||
Speaking of, let's make sure we create a directory named `src`, and maybe a Lua file inside of it:
|
||||
|
||||
```sh
|
||||
mkdir src
|
||||
echo 'print("Hello, world!")' > src/hello.lua
|
||||
```
|
||||
|
||||
## Building Your Place
|
||||
Now that we have a project, one thing we can do is build a Roblox place file for our project. This is a great way to get started with a project quickly with no fuss.
|
||||
|
||||
All we have to do is call `rojo build`:
|
||||
|
||||
```sh
|
||||
rojo build -o MyNewProject.rbxl
|
||||
```
|
||||
|
||||
If you open `MyNewProject.rbxl` in Roblox Studio now, you should see a `Folder` containing a `ModuleScript` under `ReplicatedStorage`!
|
||||
|
||||
!!! info
|
||||
To generate an XML place file instead, like if you're checking the place file into version control, just use `rbxlx` as the extension on the output file instead.
|
||||
|
||||
## Live-Syncing into Studio
|
||||
Building a place file is great for the initial build, but for actively working on your place, you'll want something quicker.
|
||||
|
||||
In Roblox Studio, make sure the Rojo plugin is installed. If you need it, check out [the installation guide](installation) to learn how to install it.
|
||||
|
||||
To expose your project to the plugin, you'll need to _serve_ it from the command line:
|
||||
|
||||
```sh
|
||||
rojo serve
|
||||
```
|
||||
|
||||
This will start up a web server that tells Roblox Studio what instances are in your project and sends notifications if any of them change.
|
||||
|
||||
Note the port number, then switch into Roblox Studio and press the Rojo **Connect** button in the plugins tab. Type in the port number, if necessary, and press **Start**.
|
||||
|
||||
If everything went well, you should now be able to change files in the `src` directory and watch them sync into Roblox Studio in real time!
|
||||
|
||||
## Uploading Your Place
|
||||
Aimed at teams that want serious levels of automation, Rojo can upload places to Roblox.com automatically.
|
||||
|
||||
You'll need an existing place on Roblox.com as well as the `.ROBLOSECURITY` cookie of an account that has write access to that place.
|
||||
|
||||
!!! warning
|
||||
It's recommended that you set up a Roblox account dedicated to deploying your place instead of your personal account in case your security cookie is compromised.
|
||||
|
||||
Generating and uploading your place file is as simple as:
|
||||
|
||||
```sh
|
||||
rojo upload --asset_id [PLACE ID] --cookie "[SECURITY COOKIE]"
|
||||
```
|
||||
@@ -1,25 +0,0 @@
|
||||
Rojo has two components:
|
||||
|
||||
* The server, a binary written in Rust
|
||||
* The plugin, a Roblox Studio plugin written in Lua
|
||||
|
||||
It's important that the plugin and server are compatible. The plugin will show errors in the Roblox Studio Output window if there is a version mismatch.
|
||||
|
||||
## Installing the Server
|
||||
To install the server, either:
|
||||
|
||||
* If you have Rust installed, use `cargo install rojo`
|
||||
* Or, download a pre-built Windows binary from [the GitHub releases page](https://github.com/LPGhatguy/rojo/releases)
|
||||
|
||||
**The Rojo binary must be run from the command line, like Terminal on MacOS or `cmd.exe` on Windows. It's recommended that you put the Rojo binary on your `PATH` to make this easier.**
|
||||
|
||||
## Installing the Plugin
|
||||
To install the plugin, either:
|
||||
|
||||
* Install the plugin from the [Roblox plugin page](https://www.roblox.com/library/1211549683/Rojo).
|
||||
* This gives you less control over what version you install -- you will always have the latest version.
|
||||
* Or, download the latest release from [the GitHub releases section](https://github.com/LPGhatguy/rojo/releases) and install it into your Roblox plugins folder
|
||||
* You can open this folder by clicking the "Plugins Folder" button from the Plugins toolbar in Roblox Studio
|
||||
|
||||
## Visual Studio Code Extension
|
||||
If you use Visual Studio Code on Windows, you can install [Evaera's unofficial Rojo extension](https://marketplace.visualstudio.com/items?itemName=evaera.vscode-rojo), which will install both halves of Rojo for you. It even has a nifty UI to add partitions and start/stop the Rojo server!
|
||||
11
docs/guide/existing-game.md
Normal file
@@ -0,0 +1,11 @@
|
||||
**This page is under construction!**
|
||||
|
||||
## Summary
|
||||
* Tools to port existing games are in progress!
|
||||
* [rbxlx-to-rojo](https://github.com/rojo-rbx/rbxlx-to-rojo)
|
||||
* `rojo export` ([issue #208](https://github.com/rojo-rbx/rojo/issues/208))
|
||||
* Can port as much or as little of your game as you like
|
||||
* Rojo can manage just a slice of your game!
|
||||
* Some Roblox idioms aren't very well supported
|
||||
* Redundant copies of scripts don't work well with files
|
||||
* Having only a couple places with scripts simplifies your project dramatically!
|
||||
46
docs/guide/installation.md
Normal file
@@ -0,0 +1,46 @@
|
||||
This is this installation guide for Rojo **0.5.x**.
|
||||
|
||||
[TOC]
|
||||
|
||||
## Overview
|
||||
Rojo has two components:
|
||||
|
||||
* The command line interface (CLI)
|
||||
* The Roblox Studio plugin
|
||||
|
||||
!!! info
|
||||
It's important that your installed version of the plugin and CLI are compatible.
|
||||
|
||||
The plugin will show errors in the Roblox Studio output window if there is a version mismatch.
|
||||
|
||||
## Visual Studio Code Extension
|
||||
If you use Visual Studio Code, you can install [the Rojo VS Code extension](https://marketplace.visualstudio.com/items?itemName=evaera.vscode-rojo), which will install both halves of Rojo for you. It even has a nifty UI to sync files and start/stop the Rojo server!
|
||||
|
||||
## Installing the CLI
|
||||
|
||||
### Installing from GitHub
|
||||
If you're on Windows, there are pre-built binaries available from Rojo's [GitHub Releases page](https://github.com/LPGhatguy/rojo/releases).
|
||||
|
||||
The Rojo CLI must be run from the command line, like Terminal.app on MacOS or `cmd.exe` on Windows. It's recommended that you put the Rojo CLI executable on your `PATH` to make this easier.
|
||||
|
||||
### Installing from Cargo
|
||||
If you have Rust installed, the easiest way to get Rojo is with Cargo!
|
||||
|
||||
To install the latest 0.5.0 alpha, use:
|
||||
|
||||
```sh
|
||||
cargo install rojo --version 0.5.0-alpha.13
|
||||
```
|
||||
|
||||
## Installing the Plugin
|
||||
|
||||
### Installing from GitHub
|
||||
The Rojo Roblox Studio plugin is available from Rojo's [GitHub Releases page](https://github.com/LPGhatguy/rojo/releases).
|
||||
|
||||
Download the attached `rbxm` file and put it into your Roblox Studio plugins folder. You can find that folder by pressing **Plugins Folder** from your Plugins toolbar in Roblox Studio:
|
||||
|
||||

|
||||
{: align="center" }
|
||||
|
||||
### Installing from Roblox.com
|
||||
Visit [Rojo's Roblox.com Plugin page](https://www.roblox.com/library/1997686364/Rojo-0-5-0-alpha-3) in Roblox Studio and press **Install**.
|
||||
@@ -1,7 +1,9 @@
|
||||
Rojo underwent a large refactor during most of 2018 to enable a bunch of new features and lay groundwork for lots more in 2019. As such, Rojo **0.5.x** projects are not compatible with Rojo **0.4.x** projects.
|
||||
|
||||
[TOC]
|
||||
|
||||
## Supporting Both 0.4.x and 0.5.x
|
||||
Rojo 0.5.x uses a different name for its project format. While 0.4.x used `rojo.json`, 0.5.x uses `roblox-project.json`, which allows them to coexist.
|
||||
Rojo 0.5.x uses a different name for its project format. While 0.4.x used `rojo.json`, 0.5.x uses `default.project.json`, which allows them to coexist.
|
||||
|
||||
If you aren't sure about upgrading or want to upgrade gradually, it's possible to keep both files in the same project without causing problems.
|
||||
|
||||
@@ -50,10 +52,12 @@ Metadata begins with a dollar sign (`$`), like `$className`. This is so that chi
|
||||
|
||||
All other values are considered children, where the key is the instance's name, and the value is an object, repeating the process.
|
||||
|
||||
## Migrating `.model.json` Files
|
||||
No upgrade path yet, stay tuned.
|
||||
|
||||
## Migrating Unknown Files
|
||||
If you used Rojo to sync in files as `StringValue` objects, you'll need to make sure those files end with the `txt` extension to preserve this in Rojo 0.5.x.
|
||||
|
||||
Unknown files are now ignored in Rojo instead of being converted to `StringValue` objects.
|
||||
Unknown files are now ignored in Rojo instead of being converted to `StringValue` objects.
|
||||
|
||||
## Migrating `init.model.json` files
|
||||
In Rojo 0.4.x, it's possible to create a file named `init.model.json` that lets you describe a model that becomes the container for all of the other files in the folder, just like `init.lua`.
|
||||
|
||||
In Rojo 0.5.x, this feature has been replaced with `init.meta.json` files. See [Sync Details](../reference/sync-details) for more information about these new files.
|
||||
90
docs/guide/new-game.md
Normal file
@@ -0,0 +1,90 @@
|
||||
[TOC]
|
||||
|
||||
## Creating the Rojo Project
|
||||
To use Rojo to build a game, you'll need to create a new project file, which tells Rojo how to turn your files into a Roblox place.
|
||||
|
||||
First, create a new folder to contain the files for your game and open up a new terminal inside of it, like cmd.exe or Bash.
|
||||
|
||||
It's convenient to make the folder from the command line:
|
||||
|
||||
```sh
|
||||
mkdir my-new-project
|
||||
cd my-new-project
|
||||
```
|
||||
|
||||
Inside the folder, initialize a new Rojo project:
|
||||
|
||||
```sh
|
||||
rojo init
|
||||
```
|
||||
|
||||
Rojo will make a small project file in your directory, named `default.project.json`. It matches the "Baseplate" template from Roblox Studio, except that it'll take any files you put in a folder called `src` and put it into `ReplicatedStorage.Source`.
|
||||
|
||||
Speaking of files, make sure to create a directory named `src` in this folder, or Rojo will be upset about missing files!
|
||||
|
||||
```sh
|
||||
mkdir src
|
||||
```
|
||||
|
||||
Let's also add a Lua file, `hello.lua` to the `src` folder, so that we can make this project our own.
|
||||
|
||||
```sh
|
||||
echo 'return "Hello, Rojo!"' > src/hello.lua
|
||||
```
|
||||
|
||||
## Building Your Place
|
||||
Now that we have a project, one thing we can do is build a Roblox place file for our project. This is a great way to get started with a project quickly with no fuss.
|
||||
|
||||
All we have to do is call `rojo build`:
|
||||
|
||||
```sh
|
||||
rojo build -o MyNewProject.rbxlx
|
||||
```
|
||||
|
||||
If you open `MyNewProject.rbxlx` in Roblox Studio now, you should see a `Folder` named "Source" containing a `ModuleScript` under `ReplicatedStorage`.
|
||||
|
||||
!!! info
|
||||
To generate a binary place file instead, use `rbxl`. Note that support for binary model/place files (`rbxm` and `rbxl`) is very limited in Rojo presently.
|
||||
|
||||
## Live-Syncing into Studio
|
||||
Building a place file is great for starting to work on a game, but for active iteration, you'll want something faster.
|
||||
|
||||
In Roblox Studio, make sure the Rojo plugin is installed. If you need it, check out [the installation guide](../installation) to learn how to install it.
|
||||
|
||||
To expose your project to the plugin, you'll need to start a new **live sync session** from the command line:
|
||||
|
||||
```sh
|
||||
rojo serve
|
||||
```
|
||||
|
||||
You should see output like this in your terminal:
|
||||
|
||||
```sh
|
||||
$ rojo serve
|
||||
Rojo server listening on port 34872
|
||||
```
|
||||
|
||||
Switch into Roblox Studio and press the **Connect** button on the Rojo plugin toolbar. A dialog should appear:
|
||||
|
||||

|
||||
{: class="feature-image" align="center" }
|
||||
|
||||
If the port number doesn't match the output from the command line, change it, and then press **Connect**.
|
||||
|
||||
If all went well, you should now be able to change files in the `src` directory and watch them sync into Roblox Studio in real time!
|
||||
|
||||
## Uploading Your Place
|
||||
Aimed at teams that want serious levels of automation, Rojo can upload places to Roblox.com automatically.
|
||||
|
||||
You'll need an existing game on Roblox.com as well as the `.ROBLOSECURITY` cookie of an account that has write access to that game.
|
||||
|
||||
!!! warning
|
||||
It's recommended that you set up a Roblox account dedicated to deploying your game instead of your personal account in case your security cookie is compromised.
|
||||
|
||||
Generating and publishing your game is as simple as:
|
||||
|
||||
```sh
|
||||
rojo upload --asset_id [PLACE ID] --cookie "[SECURITY COOKIE]"
|
||||
```
|
||||
|
||||
An example project is available on GitHub that deploys to Roblox.com from GitHub and Travis-CI automatically: [https://github.com/LPGhatguy/roads](https://github.com/LPGhatguy/roads)
|
||||
BIN
docs/images/connection-dialog.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 17 KiB |
BIN
docs/images/plugins-folder-in-studio.png
Normal file
|
After Width: | Height: | Size: 19 KiB |
17
docs/images/sync-example-files.gv
Normal file
@@ -0,0 +1,17 @@
|
||||
digraph "Sync Files" {
|
||||
graph [
|
||||
ranksep = "0.7",
|
||||
nodesep = "0.5",
|
||||
];
|
||||
node [
|
||||
fontname = "monospace",
|
||||
shape = "record",
|
||||
];
|
||||
|
||||
my_model [label = "MyModel"]
|
||||
init_server [label = "init.server.lua"]
|
||||
foo [label = "foo.lua"]
|
||||
|
||||
my_model -> init_server
|
||||
my_model -> foo
|
||||
}
|
||||
38
docs/images/sync-example-files.svg
Normal file
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Generated by graphviz version 2.38.0 (20140413.2041)
|
||||
-->
|
||||
<!-- Title: Sync Files Pages: 1 -->
|
||||
<svg width="258pt" height="132pt"
|
||||
viewBox="0.00 0.00 258.00 132.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 128)">
|
||||
<title>Sync Files</title>
|
||||
<polygon fill="white" stroke="none" points="-4,4 -4,-128 254,-128 254,4 -4,4"/>
|
||||
<!-- my_model -->
|
||||
<g id="node1" class="node"><title>my_model</title>
|
||||
<polygon fill="none" stroke="black" points="104,-87.5 104,-123.5 178,-123.5 178,-87.5 104,-87.5"/>
|
||||
<text text-anchor="middle" x="141" y="-101.8" font-family="monospace" font-size="14.00">MyModel</text>
|
||||
</g>
|
||||
<!-- init_server -->
|
||||
<g id="node2" class="node"><title>init_server</title>
|
||||
<polygon fill="none" stroke="black" points="0,-0.5 0,-36.5 140,-36.5 140,-0.5 0,-0.5"/>
|
||||
<text text-anchor="middle" x="70" y="-14.8" font-family="monospace" font-size="14.00">init.server.lua</text>
|
||||
</g>
|
||||
<!-- my_model->init_server -->
|
||||
<g id="edge1" class="edge"><title>my_model->init_server</title>
|
||||
<path fill="none" stroke="black" d="M126.632,-87.299C116.335,-74.9713 102.308,-58.1787 90.7907,-44.3902"/>
|
||||
<polygon fill="black" stroke="black" points="93.4435,-42.1065 84.3465,-36.6754 88.0711,-46.594 93.4435,-42.1065"/>
|
||||
</g>
|
||||
<!-- foo -->
|
||||
<g id="node3" class="node"><title>foo</title>
|
||||
<polygon fill="none" stroke="black" points="176,-0.5 176,-36.5 250,-36.5 250,-0.5 176,-0.5"/>
|
||||
<text text-anchor="middle" x="213" y="-14.8" font-family="monospace" font-size="14.00">foo.lua</text>
|
||||
</g>
|
||||
<!-- my_model->foo -->
|
||||
<g id="edge2" class="edge"><title>my_model->foo</title>
|
||||
<path fill="none" stroke="black" d="M155.57,-87.299C166.013,-74.9713 180.237,-58.1787 191.917,-44.3902"/>
|
||||
<polygon fill="black" stroke="black" points="194.659,-46.5681 198.451,-36.6754 189.317,-42.0437 194.659,-46.5681"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.0 KiB |
15
docs/images/sync-example-instances.gv
Normal file
@@ -0,0 +1,15 @@
|
||||
digraph "Sync Files" {
|
||||
graph [
|
||||
ranksep = "0.7",
|
||||
nodesep = "0.5",
|
||||
];
|
||||
node [
|
||||
fontname = "monospace",
|
||||
shape = "record",
|
||||
];
|
||||
|
||||
my_model [label = "MyModel (Script)"]
|
||||
foo [label = "foo (ModuleScript)"]
|
||||
|
||||
my_model -> foo
|
||||
}
|
||||
28
docs/images/sync-example-instances.svg
Normal file
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Generated by graphviz version 2.38.0 (20140413.2041)
|
||||
-->
|
||||
<!-- Title: Sync Files Pages: 1 -->
|
||||
<svg width="173pt" height="132pt"
|
||||
viewBox="0.00 0.00 173.00 132.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 128)">
|
||||
<title>Sync Files</title>
|
||||
<polygon fill="white" stroke="none" points="-4,4 -4,-128 169,-128 169,4 -4,4"/>
|
||||
<!-- my_model -->
|
||||
<g id="node1" class="node"><title>my_model</title>
|
||||
<polygon fill="none" stroke="black" points="8,-87.5 8,-123.5 157,-123.5 157,-87.5 8,-87.5"/>
|
||||
<text text-anchor="middle" x="82.5" y="-101.8" font-family="monospace" font-size="14.00">MyModel (Script)</text>
|
||||
</g>
|
||||
<!-- foo -->
|
||||
<g id="node2" class="node"><title>foo</title>
|
||||
<polygon fill="none" stroke="black" points="0,-0.5 0,-36.5 165,-36.5 165,-0.5 0,-0.5"/>
|
||||
<text text-anchor="middle" x="82.5" y="-14.8" font-family="monospace" font-size="14.00">foo (ModuleScript)</text>
|
||||
</g>
|
||||
<!-- my_model->foo -->
|
||||
<g id="edge1" class="edge"><title>my_model->foo</title>
|
||||
<path fill="none" stroke="black" d="M82.5,-87.299C82.5,-75.6626 82.5,-60.0479 82.5,-46.7368"/>
|
||||
<polygon fill="black" stroke="black" points="86.0001,-46.6754 82.5,-36.6754 79.0001,-46.6755 86.0001,-46.6754"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
17
docs/images/sync-example-json-model.gv
Normal file
@@ -0,0 +1,17 @@
|
||||
digraph "Sync Files" {
|
||||
graph [
|
||||
ranksep = "0.7",
|
||||
nodesep = "0.5",
|
||||
];
|
||||
node [
|
||||
fontname = "monospace",
|
||||
shape = "record",
|
||||
];
|
||||
|
||||
model [label = "My Cool Model (Folder)"]
|
||||
root_part [label = "RootPart (Part)"]
|
||||
send_money [label = "SendMoney (RemoteEvent)"]
|
||||
|
||||
model -> root_part
|
||||
model -> send_money
|
||||
}
|
||||
38
docs/images/sync-example-json-model.svg
Normal file
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<!-- Generated by graphviz version 2.38.0 (20140413.2041)
|
||||
-->
|
||||
<!-- Title: Sync Files Pages: 1 -->
|
||||
<svg width="390pt" height="132pt"
|
||||
viewBox="0.00 0.00 390.00 132.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 128)">
|
||||
<title>Sync Files</title>
|
||||
<polygon fill="white" stroke="none" points="-4,4 -4,-128 386,-128 386,4 -4,4"/>
|
||||
<!-- model -->
|
||||
<g id="node1" class="node"><title>model</title>
|
||||
<polygon fill="none" stroke="black" points="75,-87.5 75,-123.5 273,-123.5 273,-87.5 75,-87.5"/>
|
||||
<text text-anchor="middle" x="174" y="-101.8" font-family="monospace" font-size="14.00">My Cool Model (Folder)</text>
|
||||
</g>
|
||||
<!-- root_part -->
|
||||
<g id="node2" class="node"><title>root_part</title>
|
||||
<polygon fill="none" stroke="black" points="0,-0.5 0,-36.5 140,-36.5 140,-0.5 0,-0.5"/>
|
||||
<text text-anchor="middle" x="70" y="-14.8" font-family="monospace" font-size="14.00">RootPart (Part)</text>
|
||||
</g>
|
||||
<!-- model->root_part -->
|
||||
<g id="edge1" class="edge"><title>model->root_part</title>
|
||||
<path fill="none" stroke="black" d="M152.954,-87.299C137.448,-74.6257 116.168,-57.2335 99.0438,-43.2377"/>
|
||||
<polygon fill="black" stroke="black" points="100.972,-40.2938 91.0147,-36.6754 96.5426,-45.7138 100.972,-40.2938"/>
|
||||
</g>
|
||||
<!-- send_money -->
|
||||
<g id="node3" class="node"><title>send_money</title>
|
||||
<polygon fill="none" stroke="black" points="176,-0.5 176,-36.5 382,-36.5 382,-0.5 176,-0.5"/>
|
||||
<text text-anchor="middle" x="279" y="-14.8" font-family="monospace" font-size="14.00">SendMoney (RemoteEvent)</text>
|
||||
</g>
|
||||
<!-- model->send_money -->
|
||||
<g id="edge2" class="edge"><title>model->send_money</title>
|
||||
<path fill="none" stroke="black" d="M195.248,-87.299C210.904,-74.6257 232.388,-57.2335 249.677,-43.2377"/>
|
||||
<polygon fill="black" stroke="black" points="252.213,-45.6878 257.783,-36.6754 247.809,-40.2471 252.213,-45.6878"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
@@ -1,11 +1,11 @@
|
||||
This is the documentation home for Rojo.
|
||||
This is the documentation home for Rojo 0.5.x.
|
||||
|
||||
Available versions of these docs:
|
||||
|
||||
* [0.5.x](https://lpghatguy.github.io/rojo/0.5.x)
|
||||
* [0.4.x](https://lpghatguy.github.io/rojo/0.4.x)
|
||||
* [`master` branch](https://lpghatguy.github.io/rojo/master)
|
||||
* [Latest version (currently 0.5.x)](https://rojo.space/docs/latest)
|
||||
* [0.5.x](https://rojo.space/docs/0.5.x)
|
||||
* [0.4.x](https://rojo.space/docs/0.4.x)
|
||||
|
||||
**Rojo** is a flexible multi-tool designed for creating robust Roblox projects.
|
||||
|
||||
This documentation is a continual work in progress. If you find any issues, please file an issue on [Rojo's issue tracker](https://github.com/LPGhatguy/rojo/issues)!
|
||||
This documentation is a continual work in progress. If you find any issues, please file an issue on [Rojo's issue tracker](https://github.com/rojo-rbx/rojo/issues)!
|
||||
45
docs/internals/overview.md
Normal file
@@ -0,0 +1,45 @@
|
||||
This document aims to give a general overview of how Rojo works. It's intended for people who want to contribute to the project as well as anyone who's just curious how the tool works!
|
||||
|
||||
[TOC]
|
||||
|
||||
## CLI
|
||||
|
||||
### RbxTree
|
||||
Rojo uses a library named [`rbx_tree`](https://github.com/LPGhatguy/rbx-tree) as its implementation of the Roblox DOM. It serves as a common format for serialization to all the formats Rojo supports!
|
||||
|
||||
Rojo uses two related libraries to deserialize instances from Roblox's file formats, `rbx_xml` and `rbx_binary`.
|
||||
|
||||
### In-Memory Filesystem (IMFS)
|
||||
Relevant source files:
|
||||
|
||||
* [`server/src/imfs.rs`](https://github.com/LPGhatguy/rojo/blob/master/server/src/imfs.rs)
|
||||
* [`server/src/fs_watcher.rs`](https://github.com/LPGhatguy/rojo/blob/master/server/src/fs_watcher.rs)
|
||||
|
||||
Rojo keeps an in-memory copy of all files that it needs reasons about. This enables taking fast, stateless, tear-tree snapshots of files to turn them into instances.
|
||||
|
||||
Keeping an in-memory copy of file contents will also enable Rojo to debounce changes that are caused by Rojo itself. This'll happen when two-way sync finally happens.
|
||||
|
||||
### Snapshot Reconciler
|
||||
Relevant source files:
|
||||
|
||||
* [`server/src/snapshot_reconciler.rs`](https://github.com/LPGhatguy/rojo/blob/master/server/src/snapshot_reconciler.rs)
|
||||
* [`server/src/rbx_snapshot.rs`](https://github.com/LPGhatguy/rojo/blob/master/server/src/rbx_snapshot.rs)
|
||||
* [`server/src/rbx_session.rs`](https://github.com/LPGhatguy/rojo/blob/master/server/src/rbx_session.rs)
|
||||
|
||||
To simplify incremental updates of instances, Rojo generates lightweight snapshots describing how files map to instances. This means that Rojo can treat file change events similarly to damage painting as opposed to trying to surgically update the correct instances.
|
||||
|
||||
This approach reduces the number of desynchronization bugs, reduces the complexity of important pieces of the codebase, and makes writing plugins a lot easier.
|
||||
|
||||
### HTTP API
|
||||
Relevant source files:
|
||||
|
||||
* [`server/src/web.rs`](https://github.com/LPGhatguy/rojo/blob/master/server/src/web.rs)
|
||||
|
||||
The Rojo live-sync server and Roblox Studio plugin communicate via HTTP.
|
||||
|
||||
Requests sent from the plugin to the server are regular HTTP requests.
|
||||
|
||||
Messages sent from the server to the plugin are delivered via HTTP long-polling. This is an approach that uses long-lived HTTP requests that restart on timeout. It's largely been replaced by WebSockets, but Roblox doesn't have support for them.
|
||||
|
||||
## Roblox Studio Plugin
|
||||
TODO
|
||||
37
docs/reference/full-vs-partial.md
Normal file
@@ -0,0 +1,37 @@
|
||||
Rojo is designed to be adopted incrementally. How much of your project Rojo manages is up to you!
|
||||
|
||||
There are two primary categories of ways to use Rojo: *Fully Managed*, where everything is managed by Rojo, and *Partially Managed*, where Rojo only manages a slice of your project.
|
||||
|
||||
## Fully Managed
|
||||
In a fully managed game project, Rojo controls every instance. A fully managed Rojo project can be built from scratch using `rojo build`.
|
||||
|
||||
Fully managed projects are most practical for libraries, plugins, and simple games.
|
||||
|
||||
Rojo's goal is to make it practical and easy for _every_ project to be fully managed, but we're not quite there yet!
|
||||
|
||||
### Pros
|
||||
* Fully reproducible builds from scratch
|
||||
* Everything checked into version control
|
||||
|
||||
### Cons
|
||||
* Without two-way sync, models have to be saved manually
|
||||
* This can be done with the 'Save to File...' menu in Roblox Studio
|
||||
* This will be solved by Two-Way Sync ([issue #164](https://github.com/LPGhatguy/rojo/issues/164))
|
||||
* Rojo can't manage everything yet
|
||||
* Refs are currently broken ([issue #142](https://github.com/LPGhatguy/rojo/issues/142))
|
||||
|
||||
## Partially Managed
|
||||
In a partially managed project, Rojo only handles a slice of the game. This could be as small as a couple scripts, or as large as everything except `Workspace`!
|
||||
|
||||
The rest of the place's content can be versioned using Team Create or checked into source control.
|
||||
|
||||
Partially managed projects are most practical for complicated games, or games that are migrating to use Rojo.
|
||||
|
||||
### Pros
|
||||
* Easier to adopt gradually
|
||||
* Integrates with Team Create
|
||||
|
||||
### Cons
|
||||
* Not everything is in version control, which makes merges tougher
|
||||
* Rojo can't live-sync instances like Terrain, MeshPart, or CSG operations yet
|
||||
* Will be fixed with plugin escalation ([issue #169](https://github.com/LPGhatguy/rojo/issues/169))
|
||||
145
docs/reference/project-format.md
Normal file
@@ -0,0 +1,145 @@
|
||||
[TOC]
|
||||
|
||||
## Project File
|
||||
|
||||
Rojo projects are JSON files that have the `.project.json` extension. They have these fields:
|
||||
|
||||
* `name`: A string indicating the name of the project.
|
||||
* This is only used for diagnostics.
|
||||
* `tree`: An [Instance Description](#instance-description) describing the root instance of the project.
|
||||
|
||||
## Instance Description
|
||||
Instance Descriptions correspond one-to-one with the actual Roblox Instances in the project. They can be specified directly in the project file or be pulled from the filesystem.
|
||||
|
||||
* `$className`: The ClassName of the Instance being described.
|
||||
* Optional if `$path` is specified.
|
||||
* `$path`: The path on the filesystem to pull files from into the project.
|
||||
* Optional if `$className` is specified.
|
||||
* Paths are relative to the folder containing the project file.
|
||||
* `$properties`: Properties to apply to the instance. Values should be [Instance Property Values](#instance-property-value).
|
||||
* Optional
|
||||
* `$ignoreUnknownInstances`: Whether instances that Rojo doesn't know about should be deleted.
|
||||
* Optional
|
||||
* Default is `false` if `$path` is specified, otherwise `true`.
|
||||
|
||||
All other fields in an Instance Description are turned into instances whose name is the key. These values should also be Instance Descriptions!
|
||||
|
||||
Instance Descriptions are fairly verbose and strict. In the future, it'll be possible for Rojo to [infer class names for known services like `Workspace`](https://github.com/LPGhatguy/rojo/issues/179).
|
||||
|
||||
## Instance Property Value
|
||||
There are two kinds of property values on instances, **implicit** and **explicit**.
|
||||
|
||||
In the vast majority of cases, you should be able to use **implicit** property values. To use them, just use a value that's the same shape as the type that the property has:
|
||||
|
||||
```json
|
||||
"MyPart": {
|
||||
"$className": "Part",
|
||||
"$properties": {
|
||||
"Size": [3, 5, 3],
|
||||
"Color": [0.5, 0, 0.5],
|
||||
"Anchored": true,
|
||||
"Material": "Granite"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`Vector3` and `Color3` properties can just be arrays of numbers, as can types like `Vector2`, `CFrame`, and more!
|
||||
|
||||
Enums can be set to a string containing the enum variant. Rojo will raise an error if the string isn't a valid variant for the enum.
|
||||
|
||||
There are some cases where this syntax for assigning properties _doesn't_ work. In these cases, Rojo requires you to use the **explicit** property syntax.
|
||||
|
||||
Some reasons why you might need to use an **explicit** property:
|
||||
|
||||
* Using exotic property types like `BinaryString`
|
||||
* Using properties added to Roblox recently that Rojo doesn't know about yet
|
||||
|
||||
The shape of explicit property values is defined by the [rbx-dom](https://github.com/LPGhatguy/rbx-dom) library, so it uses slightly different conventions than the rest of Rojo.
|
||||
|
||||
Each value should be an object with the following required fields:
|
||||
|
||||
* `Type`: The type of property to represent.
|
||||
* [Supported types can be found here](https://github.com/LPGhatguy/rbx-tree#property-type-coverage).
|
||||
* `Value`: The value of the property.
|
||||
* The shape of this field depends on which property type is being used. `Vector3` and `Color3` values are both represented as a list of numbers, while `BinaryString` expects a base64-encoded string, for example.
|
||||
|
||||
Here's the same object, but with explicit properties:
|
||||
|
||||
```json
|
||||
"MyPart": {
|
||||
"$className": "Part",
|
||||
"$properties": {
|
||||
"Size": {
|
||||
"Type": "Vector3",
|
||||
"Value": [3, 5, 3]
|
||||
},
|
||||
"Color": {
|
||||
"Type": "Color3",
|
||||
"Value": [0.5, 0, 0.5]
|
||||
},
|
||||
"Anchored": {
|
||||
"Type": "Bool",
|
||||
"Value": true
|
||||
},
|
||||
"Material": {
|
||||
"Type": "Enum",
|
||||
"Value": 832
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Example Projects
|
||||
This project bundles up everything in the `src` directory. It'd be suitable for making a plugin or model:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "AwesomeLibrary",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This project describes the layout you might use if you were making the next hit simulator game, *Sisyphus Simulator*:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Sisyphus Simulator",
|
||||
"tree": {
|
||||
"$className": "DataModel",
|
||||
|
||||
"HttpService": {
|
||||
"$className": "HttpService",
|
||||
"$properties": {
|
||||
"HttpEnabled": true
|
||||
}
|
||||
},
|
||||
|
||||
"ReplicatedStorage": {
|
||||
"$className": "ReplicatedStorage",
|
||||
"$path": "src/ReplicatedStorage"
|
||||
},
|
||||
|
||||
"StarterPlayer": {
|
||||
"$className": "StarterPlayer",
|
||||
|
||||
"StarterPlayerScripts": {
|
||||
"$className": "StarterPlayerScripts",
|
||||
"$path": "src/StarterPlayerScripts"
|
||||
}
|
||||
},
|
||||
|
||||
"Workspace": {
|
||||
"$className": "Workspace",
|
||||
"$properties": {
|
||||
"Gravity": 67.3
|
||||
},
|
||||
|
||||
"Terrain": {
|
||||
"$path": "Terrain.rbxm"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
144
docs/reference/sync-details.md
Normal file
@@ -0,0 +1,144 @@
|
||||
This page aims to describe how Rojo turns files on the filesystem into Roblox objects.
|
||||
|
||||
[TOC]
|
||||
|
||||
## Overview
|
||||
| File Name | Instance Type |
|
||||
| -------------- | ------------------------- |
|
||||
| any directory | `Folder` |
|
||||
| `*.server.lua` | `Script` |
|
||||
| `*.client.lua` | `LocalScript` |
|
||||
| `*.lua` | `ModuleScript` |
|
||||
| `*.csv` | `LocalizationTable` |
|
||||
| `*.txt` | `StringValue` |
|
||||
| `*.model.json` | Any |
|
||||
| `*.rbxm` | Any |
|
||||
| `*.rbxmx` | Any |
|
||||
| `*.meta.json` | Modifies another instance |
|
||||
|
||||
## Limitations
|
||||
Not all property types can be synced by Rojo in real-time due to limitations of the Roblox Studio plugin API. In these cases, you can usually generate a place file and open it when you start working on a project.
|
||||
|
||||
Some common cases you might hit are:
|
||||
|
||||
* Binary data (Terrain, CSG, CollectionService tags)
|
||||
* `MeshPart.MeshId`
|
||||
* `HttpService.HttpEnabled`
|
||||
|
||||
For a list of all property types that Rojo can reason about, both when live-syncing and when building place files, look at [rbx-dom's type coverage chart](https://github.com/rojo-rbx/rbx-dom#property-type-coverage).
|
||||
|
||||
This limitation may be solved by [issue #205](https://github.com/rojo-rbx/rojo/issues/205) in the future.
|
||||
|
||||
## Folders
|
||||
Any directory on the filesystem will turn into a `Folder` instance unless it contains an 'init' script, described below.
|
||||
|
||||
## Scripts
|
||||
The default script type in Rojo projects is `ModuleScript`, since most scripts in well-structued Roblox projects will be modules.
|
||||
|
||||
If a directory contains a file named `init.server.lua`, `init.client.lua`, or `init.lua`, that folder will be transformed into a `*Script` instance with the contents of the 'init' file. This can be used to create scripts inside of scripts.
|
||||
|
||||
For example, these files:
|
||||
|
||||

|
||||
{: align="center" }
|
||||
|
||||
Will turn into these instances in Roblox:
|
||||
|
||||

|
||||
{: align="center" }
|
||||
|
||||
## Localization Tables
|
||||
Any CSV files are transformed into `LocalizationTable` instances. Rojo expects these files to follow the same format that Roblox does when importing and exporting localization information.
|
||||
|
||||
## Plain Text Files
|
||||
Plain text files (`.txt`) files are transformed into `StringValue` instances. This is useful for bringing in text data that can be read by scripts at runtime.
|
||||
|
||||
## JSON Models
|
||||
Files ending in `.model.json` can be used to describe simple models. They're designed to be hand-written and are useful for instances like `RemoteEvent`.
|
||||
|
||||
A JSON model describing a folder containing a `Part` and a `RemoteEvent` could be described as:
|
||||
|
||||
```json
|
||||
{
|
||||
"Name": "My Cool Model",
|
||||
"ClassName": "Folder",
|
||||
"Children": [
|
||||
{
|
||||
"Name": "RootPart",
|
||||
"ClassName": "Part",
|
||||
"Properties": {
|
||||
"Size": {
|
||||
"Type": "Vector3",
|
||||
"Value": [4, 4, 4]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"Name": "SendMoney",
|
||||
"ClassName": "RemoteEvent"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
It would turn into instances in this shape:
|
||||
|
||||

|
||||
{: align="center" }
|
||||
|
||||
## Binary and XML Models
|
||||
Rojo supports both binary (`.rbxm`) and XML (`.rbxmx`) models generated by Roblox Studio or another tool.
|
||||
|
||||
Support for the `rbxmx` is very good, while support for `rbxm` is still very early, buggy, and lacking features.
|
||||
|
||||
For a rundown of supported types, check out [rbx-dom's type coverage chart](https://github.com/rojo-rbx/rbx-dom#property-type-coverage).
|
||||
|
||||
## Meta Files
|
||||
New in Rojo 0.5.0-alpha.12 are meta files, named `.meta.json`.
|
||||
|
||||
Meta files allow attaching extra Rojo data to models defined in other formats, like Roblox's `rbxm` and `rbxmx` model formats, or even Lua scripts.
|
||||
|
||||
This can be used to set Rojo-specific settings like `ignoreUnknownInstances`, or can be used to set properties like `Disabled` on a script.
|
||||
|
||||
Meta files can contain:
|
||||
|
||||
* `className`: Changes the `className` of a containing `Folder` into something else.
|
||||
* Usable only in `init.meta.json` files
|
||||
* `properties`: A map of properties to set on the instance, just like projects
|
||||
* Usable on anything except `.rbxmx`, `.rbxm`, and `.model.json` files, which already have properties
|
||||
* `ignoreUnknownInstances`: Works just like `$ignoreUnknownInstances` in project files
|
||||
|
||||
### Meta Files to set Rojo metadata
|
||||
Sometimes it's useful to apply properties like `ignoreUnknownInstances` on instances that are defined on the filesystem instead of within the project itself.
|
||||
|
||||
### Meta Files for Disabled Scripts
|
||||
Meta files can be used to set properties on `Script` instances, like `Disabled`.
|
||||
|
||||
If your project had `foo.server.lua` and you wanted to make sure it would be disabled, you could create a `foo.meta.json` next to it with:
|
||||
|
||||
```json
|
||||
{
|
||||
"properties": {
|
||||
"Disabled": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Meta Files for Tools
|
||||
If you wanted to represent a tool containing a script and a model for its handle, create a directory with an `init.meta.json` file in it:
|
||||
|
||||
```json
|
||||
{
|
||||
"className": "Tool",
|
||||
"properties": {
|
||||
"Grip": [
|
||||
0, 0, 0,
|
||||
1, 0, 0,
|
||||
0, 1, 0,
|
||||
0, 0, 1,
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Instead of a `Folder` instance, you'll end up with a `Tool` instance with the `Grip` property set!
|
||||
23
docs/rojo-alternatives.md
Normal file
@@ -0,0 +1,23 @@
|
||||
There are a number of existing plugins for Roblox that move code from the filesystem into Roblox.
|
||||
|
||||
Besides Rojo, you might consider:
|
||||
|
||||
* [rbxmk by Anaminus](https://github.com/anaminus/rbxmk)
|
||||
* [Rofresh by Osyris](https://github.com/osyrisrblx/rofresh)
|
||||
* [RbxRefresh by Osyris](https://github.com/osyrisrblx/RbxRefresh)
|
||||
* [Studio Bridge by Vocksel](https://github.com/vocksel/studio-bridge)
|
||||
* [Elixir by Vocksel](https://github.com/vocksel/elixir)
|
||||
* [RbxSync by evaera](https://github.com/evaera/RbxSync)
|
||||
* [CodeSync by MemoryPenguin](https://github.com/MemoryPenguin/CodeSync)
|
||||
* [rbx-exteditor by MemoryPenguin](https://github.com/MemoryPenguin/rbx-exteditor)
|
||||
|
||||
So why did I build Rojo?
|
||||
|
||||
Each of these tools solves what is essentially the same problem from a few different angles. The goal of Rojo is to take all of the lessons and ideas learned from these projects and build a tool that can solve this problem for good.
|
||||
|
||||
Additionally:
|
||||
|
||||
* I think that this tool needs to be built in a compiled language without a runtime, for easy distribution and good performance.
|
||||
* I think that the conventions promoted by other sync plugins (`.module.lua` for modules, as well a single sync point) are sub-optimal.
|
||||
* I think that I have a good enough understanding of the problem to build something robust.
|
||||
* I think that Rojo should be able to do more than just sync code.
|
||||
@@ -1,35 +0,0 @@
|
||||
This page aims to describe how Rojo turns files on the filesystem into Roblox objects.
|
||||
|
||||
## Overview
|
||||
| File Name | Instance Type |
|
||||
| -------------- | ------------------- |
|
||||
| any directory | `Folder` |
|
||||
| `*.server.lua` | `Script` |
|
||||
| `*.client.lua` | `LocalScript` |
|
||||
| `*.lua` | `ModuleScript` |
|
||||
| `*.csv` | `LocalizationTable` |
|
||||
| `*.txt` | `StringValue` |
|
||||
|
||||
## Folders
|
||||
Any directory on the filesystem will turn into a `Folder` instance unless it contains an 'init' script, described below.
|
||||
|
||||
## Scripts
|
||||
The default script type in Rojo projects is `ModuleScript`, since most scripts in well-structued Roblox projects will be modules.
|
||||
|
||||
If a directory contains a file named `init.server.lua`, `init.client.lua`, or `init.lua`, that folder will be transformed into a `*Script` instance with the contents of the 'init' file. This can be used to create scripts inside of scripts.
|
||||
|
||||
For example, these files:
|
||||
|
||||
* my-game
|
||||
* init.client.lua
|
||||
* foo.lua
|
||||
|
||||
Will turn into these instances in Roblox:
|
||||
|
||||

|
||||
|
||||
## Localization Tables
|
||||
Any CSV files are transformed into `LocalizationTable` instances. Rojo expects these files to follow the same format that Roblox does when importing and exporting localization information.
|
||||
|
||||
## Plain Text Files
|
||||
Plain text files (`.txt`) files are transformed into `StringValue` instances. This is useful for bringing in text data that can be read by scripts at runtime.
|
||||
@@ -1,20 +1,39 @@
|
||||
There are a number of existing plugins for Roblox that move code from the filesystem into Roblox.
|
||||
Adding a tool like Rojo to your Roblox workflow can be daunting, but it comes with some key advantages.
|
||||
|
||||
Besides Rojo, there is:
|
||||
[TOC]
|
||||
|
||||
* [Studio Bridge](https://github.com/vocksel/studio-bridge) by [Vocksel](https://github.com/vocksel)
|
||||
* [RbxRefresh](https://github.com/osyrisrblx/RbxRefresh) by [Osyris](https://github.com/osyrisrblx)
|
||||
* [RbxSync](https://github.com/evaera/RbxSync) by [evaera](https://github.com/evaera)
|
||||
* [CodeSync](https://github.com/MemoryPenguin/CodeSync) and [rbx-exteditor](https://github.com/MemoryPenguin/rbx-exteditor) by [MemoryPenguin](https://github.com/MemoryPenguin)
|
||||
* [rbxmk](https://github.com/anaminus/rbxmk) by [Anaminus](https://github.com/anaminus)
|
||||
## External Text Editors
|
||||
Rojo opens the door to use the absolute best text editors in the world and their rich plugin ecosystems.
|
||||
|
||||
So why did I build Rojo?
|
||||
Some very popular editors include [Visual Studio Code](https://code.visualstudio.com) and [Sublime Text](https://www.sublimetext.com).
|
||||
|
||||
Each of these tools solves what is essentially the same problem from a few different angles. The goal of Rojo is to take all of the lessons and ideas learned from these projects and build a tool that can solve the problem for good.
|
||||
These advanced text editors have features like multi-cursor editing, goto symbol, multi-file regex find and replace, bookmarks and much more.
|
||||
|
||||
Additionally:
|
||||
Many Rojo VS Code users also use extensions like:
|
||||
|
||||
* I think that this tool needs to be built in a compiled language without a runtime, for easy distribution and good performance.
|
||||
* I think that the conventions promoted by other sync plugins (`.module.lua` for modules, as well a single sync point) are sub-optimal.
|
||||
* I think that I have a good enough understanding of the problem to build something robust.
|
||||
* I think that Rojo should be able to do more than just sync code.
|
||||
* [vscode-rbxlua](https://marketplace.visualstudio.com/items?itemName=AmaranthineCodices.vscode-rbxlua)
|
||||
* [Roblox Lua Autocompletes](https://marketplace.visualstudio.com/items?itemName=Kampfkarren.roblox-lua-autofills)
|
||||
* [TabNine](https://tabnine.com)
|
||||
|
||||
## Version Control
|
||||
By building your game (or just the scripts) as individual files on the filesystem, it becomes easy to start using professional-grade version control tools like [Git](https://git-scm.com) and [GitHub](https://github.com).
|
||||
|
||||
Hundreds of thousands of companies and individual developers use Git to version their software projects. With Rojo, Roblox developers can take advantage of the best collaboration tool around.
|
||||
|
||||
Using a repository hosting service like GitHub or GitLab brings powerful features to Roblox developers like code reviews and issue tracking that professional engineers can't live without.
|
||||
|
||||
## TypeScript
|
||||
TypeScript enables static type safety, which helps prevent typos and adds unparalleled autocompletion. It also brings features like arrow functions, object destructuring, functional programming methods, and more!
|
||||
|
||||
With Rojo, you can use [roblox-ts](https://roblox-ts.github.io) to compile TypeScript to Lua and take advantage of a huge ecosystem of TypeScript tooling.
|
||||
|
||||
It's also possible to use other languages that compile to Lua like [MoonScript](https://moonscript.org) and [Haxe](https://haxe.org).
|
||||
|
||||
## Other Tools
|
||||
There are decades of excellent tools available that operate on files. With Rojo, it's possible to take advantage of any of them!
|
||||
|
||||
Popular tools include:
|
||||
|
||||
* [luacheck](https://github.com/mpeterv/luacheck), a static analysis tool to help you write better Lua
|
||||
* [ripgrep](https://github.com/BurntSushi/ripgrep), an extremely fast code search tool
|
||||
* [Tokei](https://github.com/XAMPPRocky/tokei), a tool for statistics like lines of code
|
||||
@@ -1,25 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
# Kludged documentation generator to support multiple versions.
|
||||
# Make sure the `site` folder is a checkout of this repository's `gh-pages`
|
||||
# branch.
|
||||
# To use, copy this file to `generate-docs.run` so that Git will leave it alone,
|
||||
# then run `generate-docs.run` in the root of the repository.
|
||||
|
||||
set -e
|
||||
|
||||
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD)
|
||||
|
||||
echo "Building 0.4.x"
|
||||
git checkout v0.4.x
|
||||
git pull
|
||||
mkdocs build --site-dir site/0.4.x
|
||||
|
||||
echo "Building master"
|
||||
git checkout master
|
||||
mkdocs build --site-dir site/master
|
||||
|
||||
echo "Building 0.5.x"
|
||||
mkdocs build --site-dir site/0.5.x
|
||||
|
||||
git checkout "$CURRENT_BRANCH"
|
||||
24
mkdocs.yml
@@ -1,6 +1,6 @@
|
||||
site_name: Rojo Documentation
|
||||
repo_name: LPGhatguy/rojo
|
||||
repo_url: https://github.com/LPGhatguy/rojo
|
||||
repo_name: rojo-rbx/rojo
|
||||
repo_url: https://github.com/rojo-rbx/rojo
|
||||
|
||||
theme:
|
||||
name: material
|
||||
@@ -11,11 +11,21 @@ theme:
|
||||
nav:
|
||||
- Home: index.md
|
||||
- Why Rojo?: why-rojo.md
|
||||
- Getting Started:
|
||||
- Installation: getting-started/installation.md
|
||||
- Creating a Place with Rojo: getting-started/creating-a-place.md
|
||||
- Sync Details: sync-details.md
|
||||
- Migrating from 0.4.x to 0.5.x: migrating-to-epiphany.md
|
||||
- Guide:
|
||||
- Installation: guide/installation.md
|
||||
- Creating a Game with Rojo: guide/new-game.md
|
||||
- Porting an Existing Game to Rojo: guide/existing-game.md
|
||||
- Migrating from 0.4.x to 0.5.x: guide/migrating-to-epiphany.md
|
||||
- Reference:
|
||||
- Fully vs Partially Managed Rojo: reference/full-vs-partial.md
|
||||
- Project Format: reference/project-format.md
|
||||
- Sync Details: reference/sync-details.md
|
||||
- Rojo Alternatives: rojo-alternatives.md
|
||||
- Rojo Internals:
|
||||
- Internals Overview: internals/overview.md
|
||||
|
||||
extra_css:
|
||||
- extra.css
|
||||
|
||||
markdown_extensions:
|
||||
- attr_list
|
||||
|
||||
@@ -13,6 +13,7 @@ stds.roblox = {
|
||||
|
||||
-- Types
|
||||
"Vector2", "Vector3",
|
||||
"Vector2int16", "Vector3int16",
|
||||
"Color3",
|
||||
"UDim", "UDim2",
|
||||
"Rect",
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# Rojo Plugin
|
||||
|
||||
This is the source to the Rojo Roblox Studio plugin.
|
||||
|
||||
Documentation is WIP.
|
||||
@@ -6,16 +6,16 @@
|
||||
"$path": "src"
|
||||
},
|
||||
"Roact": {
|
||||
"$path": "modules/roact/lib"
|
||||
},
|
||||
"Rodux": {
|
||||
"$path": "modules/rodux/lib"
|
||||
},
|
||||
"RoactRodux": {
|
||||
"$path": "modules/roact-rodux/lib"
|
||||
"$path": "modules/roact/src"
|
||||
},
|
||||
"Promise": {
|
||||
"$path": "modules/promise/lib"
|
||||
},
|
||||
"t": {
|
||||
"$path": "modules/t/lib"
|
||||
},
|
||||
"RbxDom": {
|
||||
"$path": "modules/rbx-dom/rbx_dom_lua/src"
|
||||
}
|
||||
}
|
||||
}
|
||||
1
plugin/modules/rbx-dom
Submodule
1
plugin/modules/t
Submodule
@@ -13,16 +13,13 @@
|
||||
"$path": "src"
|
||||
},
|
||||
"Roact": {
|
||||
"$path": "modules/roact/lib"
|
||||
},
|
||||
"Rodux": {
|
||||
"$path": "modules/rodux/lib"
|
||||
},
|
||||
"RoactRodux": {
|
||||
"$path": "modules/roact-rodux/lib"
|
||||
"$path": "modules/roact/src"
|
||||
},
|
||||
"Promise": {
|
||||
"$path": "modules/promise/lib"
|
||||
},
|
||||
"t": {
|
||||
"$path": "modules/t/lib"
|
||||
}
|
||||
},
|
||||
"TestEZ": {
|
||||
@@ -40,8 +37,8 @@
|
||||
}
|
||||
},
|
||||
|
||||
"TestService": {
|
||||
"$className": "TestService",
|
||||
"ServerScriptService": {
|
||||
"$className": "ServerScriptService",
|
||||
|
||||
"TestBootstrap": {
|
||||
"$path": "testBootstrap.server.lua"
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"name": "rojo",
|
||||
"servePort": 8000,
|
||||
"partitions": {
|
||||
"plugin": {
|
||||
"path": "src",
|
||||
"target": "ReplicatedStorage.Rojo.Plugin"
|
||||
},
|
||||
"modules/roact": {
|
||||
"path": "modules/roact/lib",
|
||||
"target": "ReplicatedStorage.Rojo.Roact"
|
||||
},
|
||||
"modules/rodux": {
|
||||
"path": "modules/rodux/lib",
|
||||
"target": "ReplicatedStorage.Rojo.Rodux"
|
||||
},
|
||||
"modules/roact-rodux": {
|
||||
"path": "modules/roact-rodux/lib",
|
||||
"target": "ReplicatedStorage.Rojo.RoactRodux"
|
||||
},
|
||||
"modules/promise": {
|
||||
"path": "modules/promise/lib",
|
||||
"target": "ReplicatedStorage.Rojo.Promise"
|
||||
},
|
||||
"modules/testez": {
|
||||
"path": "modules/testez/lib",
|
||||
"target": "ReplicatedStorage.TestEZ"
|
||||
},
|
||||
"tests": {
|
||||
"path": "testBootstrap.server.lua",
|
||||
"target": "TestService.testBootstrap"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,14 +140,18 @@ end
|
||||
function ApiContext:retrieveMessages()
|
||||
local url = ("%s/api/subscribe/%s"):format(self.baseUrl, self.messageCursor)
|
||||
|
||||
return Http.get(url)
|
||||
:catch(function(err)
|
||||
if err.type == HttpError.Error.Timeout then
|
||||
return self:retrieveMessages()
|
||||
end
|
||||
local function sendRequest()
|
||||
return Http.get(url)
|
||||
:catch(function(err)
|
||||
if err.type == HttpError.Error.Timeout then
|
||||
return sendRequest()
|
||||
end
|
||||
|
||||
return Promise.reject(err)
|
||||
end)
|
||||
return Promise.reject(err)
|
||||
end)
|
||||
end
|
||||
|
||||
return sendRequest()
|
||||
:andThen(rejectFailedRequests)
|
||||
:andThen(function(response)
|
||||
local body = response:json()
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
local sheetAsset = "rbxassetid://2738712459"
|
||||
|
||||
local Assets = {
|
||||
Sprites = {
|
||||
WhiteCross = {
|
||||
asset = sheetAsset,
|
||||
asset = "rbxassetid://2738712459",
|
||||
offset = Vector2.new(190, 318),
|
||||
size = Vector2.new(18, 18),
|
||||
},
|
||||
@@ -17,7 +15,8 @@ local Assets = {
|
||||
},
|
||||
},
|
||||
Images = {
|
||||
Logo = "rbxassetid://2773210620",
|
||||
Logo = "rbxassetid://3405346157",
|
||||
Icon = "rbxassetid://3405341609",
|
||||
},
|
||||
StartSession = "",
|
||||
SessionActive = "",
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
local Roact = require(script:FindFirstAncestor("Rojo").Roact)
|
||||
local Rojo = script:FindFirstAncestor("Rojo")
|
||||
local Plugin = Rojo.Plugin
|
||||
|
||||
local Plugin = script:FindFirstAncestor("Plugin")
|
||||
local Roact = require(Rojo.Roact)
|
||||
|
||||
local Assets = require(Plugin.Assets)
|
||||
local Session = require(Plugin.Session)
|
||||
local Config = require(Plugin.Config)
|
||||
local Version = require(Plugin.Version)
|
||||
local Logging = require(Plugin.Logging)
|
||||
local DevSettings = require(Plugin.DevSettings)
|
||||
local Logging = require(Plugin.Logging)
|
||||
local Session = require(Plugin.Session)
|
||||
local Version = require(Plugin.Version)
|
||||
local preloadAssets = require(Plugin.preloadAssets)
|
||||
|
||||
local ConnectPanel = require(Plugin.Components.ConnectPanel)
|
||||
local ConnectionActivePanel = require(Plugin.Components.ConnectionActivePanel)
|
||||
@@ -53,8 +55,6 @@ end
|
||||
local SessionStatus = {
|
||||
Disconnected = "Disconnected",
|
||||
Connected = "Connected",
|
||||
ConfiguringSession = "ConfiguringSession",
|
||||
-- TODO: Error?
|
||||
}
|
||||
|
||||
setmetatable(SessionStatus, {
|
||||
@@ -70,12 +70,41 @@ function App:init()
|
||||
sessionStatus = SessionStatus.Disconnected,
|
||||
})
|
||||
|
||||
self.connectButton = nil
|
||||
self.signals = {}
|
||||
self.currentSession = nil
|
||||
|
||||
self.displayedVersion = DevSettings:isEnabled()
|
||||
and Config.codename
|
||||
or Version.display(Config.version)
|
||||
|
||||
local toolbar = self.props.plugin:CreateToolbar("Rojo " .. self.displayedVersion)
|
||||
|
||||
self.toggleButton = toolbar:CreateButton(
|
||||
"Rojo",
|
||||
"Show or hide the Rojo panel",
|
||||
Assets.Images.Icon)
|
||||
self.toggleButton.ClickableWhenViewportHidden = true
|
||||
self.toggleButton.Click:Connect(function()
|
||||
self.dockWidget.Enabled = not self.dockWidget.Enabled
|
||||
end)
|
||||
|
||||
local widgetInfo = DockWidgetPluginGuiInfo.new(
|
||||
Enum.InitialDockState.Right,
|
||||
false, -- Initially enabled state
|
||||
false, -- Whether to override the widget's previous state
|
||||
360, 190, -- Floating size
|
||||
360, 190 -- Minimum size
|
||||
)
|
||||
|
||||
self.dockWidget = self.props.plugin:CreateDockWidgetPluginGui("Rojo-0.5.x", widgetInfo)
|
||||
self.dockWidget.Name = "Rojo " .. self.displayedVersion
|
||||
self.dockWidget.Title = "Rojo " .. self.displayedVersion
|
||||
self.dockWidget.AutoLocalize = false
|
||||
self.dockWidget.ZIndexBehavior = Enum.ZIndexBehavior.Sibling
|
||||
|
||||
self.signals.dockWidgetEnabled = self.dockWidget:GetPropertyChangedSignal("Enabled"):Connect(function()
|
||||
self.toggleButton:SetActive(self.dockWidget.Enabled)
|
||||
end)
|
||||
end
|
||||
|
||||
function App:render()
|
||||
@@ -97,7 +126,7 @@ function App:render()
|
||||
end,
|
||||
}),
|
||||
}
|
||||
elseif self.state.sessionStatus == SessionStatus.ConfiguringSession then
|
||||
elseif self.state.sessionStatus == SessionStatus.Disconnected then
|
||||
children = {
|
||||
ConnectPanel = e(ConnectPanel, {
|
||||
startSession = function(address, port)
|
||||
@@ -134,61 +163,26 @@ function App:render()
|
||||
}
|
||||
end
|
||||
|
||||
return e("ScreenGui", {
|
||||
AutoLocalize = false,
|
||||
ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
|
||||
return Roact.createElement(Roact.Portal, {
|
||||
target = self.dockWidget,
|
||||
}, children)
|
||||
end
|
||||
|
||||
function App:didMount()
|
||||
Logging.trace("Rojo %s initializing", self.displayedVersion)
|
||||
|
||||
local toolbar = self.props.plugin:CreateToolbar("Rojo " .. self.displayedVersion)
|
||||
|
||||
self.connectButton = toolbar:CreateButton(
|
||||
"Connect",
|
||||
"Connect to a running Rojo session",
|
||||
Assets.StartSession)
|
||||
self.connectButton.ClickableWhenViewportHidden = false
|
||||
self.connectButton.Click:Connect(function()
|
||||
checkUpgrade(self.props.plugin)
|
||||
|
||||
if self.state.sessionStatus == SessionStatus.Connected then
|
||||
Logging.trace("Disconnecting session")
|
||||
|
||||
self.currentSession:disconnect()
|
||||
self.currentSession = nil
|
||||
self:setState({
|
||||
sessionStatus = SessionStatus.Disconnected,
|
||||
})
|
||||
|
||||
Logging.trace("Session terminated by user")
|
||||
elseif self.state.sessionStatus == SessionStatus.Disconnected then
|
||||
Logging.trace("Starting session configuration")
|
||||
|
||||
self:setState({
|
||||
sessionStatus = SessionStatus.ConfiguringSession,
|
||||
})
|
||||
elseif self.state.sessionStatus == SessionStatus.ConfiguringSession then
|
||||
Logging.trace("Canceling session configuration")
|
||||
|
||||
self:setState({
|
||||
sessionStatus = SessionStatus.Disconnected,
|
||||
})
|
||||
end
|
||||
end)
|
||||
checkUpgrade(self.props.plugin)
|
||||
preloadAssets()
|
||||
end
|
||||
|
||||
function App:didUpdate()
|
||||
local connectActive = self.state.sessionStatus == SessionStatus.ConfiguringSession
|
||||
or self.state.sessionStatus == SessionStatus.Connected
|
||||
function App:willUnmount()
|
||||
if self.currentSession ~= nil then
|
||||
self.currentSession:disconnect()
|
||||
self.currentSession = nil
|
||||
end
|
||||
|
||||
self.connectButton:SetActive(connectActive)
|
||||
|
||||
if self.state.sessionStatus == SessionStatus.Connected then
|
||||
self.connectButton.Icon = Assets.SessionActive
|
||||
else
|
||||
self.connectButton.Icon = Assets.StartSession
|
||||
for _, signal in pairs(self.signals) do
|
||||
signal:Disconnect()
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -4,39 +4,19 @@ local Plugin = Rojo.Plugin
|
||||
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 joinBindings = require(Plugin.joinBindings)
|
||||
|
||||
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 RoundBox = Assets.Slices.RoundBox
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
local ConnectPanel = Roact.Component:extend("ConnectPanel")
|
||||
|
||||
function ConnectPanel:init()
|
||||
self.footerSize, self.setFooterSize = Roact.createBinding(Vector2.new())
|
||||
self.footerVersionSize, self.setFooterVersionSize = Roact.createBinding(Vector2.new())
|
||||
|
||||
-- This is constructed in init because 'joinBindings' is a hack and we'd
|
||||
-- leak memory constructing it every render. When this kind of feature lands
|
||||
-- in Roact properly, we can do this inline in render without fear.
|
||||
self.footerRestSize = joinBindings(
|
||||
{
|
||||
self.footerSize,
|
||||
self.footerVersionSize,
|
||||
},
|
||||
function(container, other)
|
||||
return UDim2.new(0, container.X - other.X - 16, 0, 32)
|
||||
end
|
||||
)
|
||||
|
||||
self:setState({
|
||||
address = "",
|
||||
port = "",
|
||||
@@ -45,24 +25,14 @@ end
|
||||
|
||||
function ConnectPanel:render()
|
||||
local startSession = self.props.startSession
|
||||
local cancel = self.props.cancel
|
||||
|
||||
return e(FitList, {
|
||||
containerKind = "ImageLabel",
|
||||
containerProps = {
|
||||
Image = RoundBox.asset,
|
||||
ImageRectOffset = RoundBox.offset,
|
||||
ImageRectSize = RoundBox.size,
|
||||
SliceCenter = RoundBox.center,
|
||||
ScaleType = Enum.ScaleType.Slice,
|
||||
BackgroundTransparency = 1,
|
||||
Position = UDim2.new(0.5, 0, 0.5, 0),
|
||||
AnchorPoint = Vector2.new(0.5, 0.5),
|
||||
},
|
||||
layoutProps = {
|
||||
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,
|
||||
@@ -96,7 +66,7 @@ function ConnectPanel:render()
|
||||
Font = Theme.TitleFont,
|
||||
TextSize = 20,
|
||||
Text = "Address",
|
||||
TextColor3 = Theme.AccentColor,
|
||||
TextColor3 = Theme.PrimaryColor,
|
||||
}),
|
||||
|
||||
Input = e(FormTextInput, {
|
||||
@@ -129,7 +99,7 @@ function ConnectPanel:render()
|
||||
Font = Theme.TitleFont,
|
||||
TextSize = 20,
|
||||
Text = "Port",
|
||||
TextColor3 = Theme.AccentColor,
|
||||
TextColor3 = Theme.PrimaryColor,
|
||||
}),
|
||||
|
||||
Input = e(FormTextInput, {
|
||||
@@ -165,17 +135,6 @@ function ConnectPanel:render()
|
||||
PaddingRight = UDim.new(0, 24),
|
||||
},
|
||||
}, {
|
||||
e(FormButton, {
|
||||
layoutOrder = 1,
|
||||
text = "Cancel",
|
||||
onClick = function()
|
||||
if cancel ~= nil then
|
||||
cancel()
|
||||
end
|
||||
end,
|
||||
secondary = true,
|
||||
}),
|
||||
|
||||
e(FormButton, {
|
||||
layoutOrder = 2,
|
||||
text = "Connect",
|
||||
@@ -196,65 +155,6 @@ function ConnectPanel:render()
|
||||
end,
|
||||
}),
|
||||
}),
|
||||
|
||||
Footer = e(FitList, {
|
||||
fitAxes = "Y",
|
||||
containerKind = "ImageLabel",
|
||||
containerProps = {
|
||||
Image = RoundBox.asset,
|
||||
ImageRectOffset = RoundBox.offset + Vector2.new(0, RoundBox.size.Y / 2),
|
||||
ImageRectSize = RoundBox.size * Vector2.new(1, 0.5),
|
||||
SliceCenter = RoundBox.center,
|
||||
ScaleType = Enum.ScaleType.Slice,
|
||||
ImageColor3 = Theme.SecondaryColor,
|
||||
Size = UDim2.new(1, 0, 0, 0),
|
||||
LayoutOrder = 3,
|
||||
BackgroundTransparency = 1,
|
||||
|
||||
[Roact.Change.AbsoluteSize] = function(rbx)
|
||||
self.setFooterSize(rbx.AbsoluteSize)
|
||||
end,
|
||||
},
|
||||
layoutProps = {
|
||||
FillDirection = Enum.FillDirection.Horizontal,
|
||||
HorizontalAlignment = Enum.HorizontalAlignment.Center,
|
||||
VerticalAlignment = Enum.VerticalAlignment.Center,
|
||||
},
|
||||
paddingProps = {
|
||||
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 = self.footerRestSize,
|
||||
}, {
|
||||
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(FitText, {
|
||||
Font = Theme.TitleFont,
|
||||
TextSize = 18,
|
||||
Text = Version.display(Config.version),
|
||||
TextXAlignment = Enum.TextXAlignment.Right,
|
||||
TextColor3 = Theme.LightTextColor,
|
||||
BackgroundTransparency = 1,
|
||||
|
||||
[Roact.Change.AbsoluteSize] = function(rbx)
|
||||
self.setFooterVersionSize(rbx.AbsoluteSize)
|
||||
end,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
end
|
||||
|
||||
|
||||
@@ -3,63 +3,42 @@ local Roact = require(script:FindFirstAncestor("Rojo").Roact)
|
||||
local Plugin = script:FindFirstAncestor("Plugin")
|
||||
|
||||
local Theme = require(Plugin.Theme)
|
||||
local Assets = require(Plugin.Assets)
|
||||
|
||||
local FitList = require(Plugin.Components.FitList)
|
||||
local Panel = require(Plugin.Components.Panel)
|
||||
local FitText = require(Plugin.Components.FitText)
|
||||
local FormButton = require(Plugin.Components.FormButton)
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
local RoundBox = Assets.Slices.RoundBox
|
||||
local WhiteCross = Assets.Sprites.WhiteCross
|
||||
local ConnectionActivePanel = Roact.Component:extend("ConnectionActivePanel")
|
||||
|
||||
local function ConnectionActivePanel(props)
|
||||
local stopSession = props.stopSession
|
||||
function ConnectionActivePanel:render()
|
||||
local stopSession = self.props.stopSession
|
||||
|
||||
return e(FitList, {
|
||||
containerKind = "ImageLabel",
|
||||
containerProps = {
|
||||
Image = RoundBox.asset,
|
||||
ImageRectOffset = RoundBox.offset + Vector2.new(0, RoundBox.size.Y / 2),
|
||||
ImageRectSize = RoundBox.size * Vector2.new(1, 0.5),
|
||||
SliceCenter = Rect.new(4, 4, 4, 4),
|
||||
ScaleType = Enum.ScaleType.Slice,
|
||||
BackgroundTransparency = 1,
|
||||
Position = UDim2.new(0.5, 0, 0, 0),
|
||||
AnchorPoint = Vector2.new(0.5, 0),
|
||||
},
|
||||
layoutProps = {
|
||||
FillDirection = Enum.FillDirection.Horizontal,
|
||||
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 = "Rojo Connected",
|
||||
Text = "Connected to Live-Sync Server",
|
||||
TextColor3 = Theme.PrimaryColor,
|
||||
BackgroundTransparency = 1,
|
||||
}),
|
||||
|
||||
CloseContainer = e("ImageButton", {
|
||||
Size = UDim2.new(0, 30, 0, 30),
|
||||
BackgroundTransparency = 1,
|
||||
|
||||
[Roact.Event.Activated] = function()
|
||||
DisconnectButton = e(FormButton, {
|
||||
layoutOrder = 2,
|
||||
text = "Disconnect",
|
||||
secondary = true,
|
||||
onClick = function()
|
||||
stopSession()
|
||||
end,
|
||||
}, {
|
||||
CloseImage = e("ImageLabel", {
|
||||
Size = UDim2.new(0, 16, 0, 16),
|
||||
Position = UDim2.new(0.5, 0, 0.5, 0),
|
||||
AnchorPoint = Vector2.new(0.5, 0.5),
|
||||
Image = WhiteCross.asset,
|
||||
ImageRectOffset = WhiteCross.offset,
|
||||
ImageRectSize = WhiteCross.size,
|
||||
ImageColor3 = Theme.PrimaryColor,
|
||||
BackgroundTransparency = 1,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
end
|
||||
|
||||
@@ -57,8 +57,8 @@ function FormTextInput:render()
|
||||
TextSize = TEXT_SIZE,
|
||||
Text = value,
|
||||
PlaceholderText = shownPlaceholder,
|
||||
PlaceholderColor3 = Theme.AccentLightColor,
|
||||
TextColor3 = Theme.AccentColor,
|
||||
PlaceholderColor3 = Theme.LightTextColor,
|
||||
TextColor3 = Theme.PrimaryColor,
|
||||
|
||||
[Roact.Change.Text] = function(rbx)
|
||||
onValueChange(rbx.Text)
|
||||
|
||||
34
plugin/src/Components/Panel.lua
Normal file
@@ -0,0 +1,34 @@
|
||||
local Roact = require(script:FindFirstAncestor("Rojo").Roact)
|
||||
|
||||
local Plugin = script:FindFirstAncestor("Plugin")
|
||||
|
||||
local RojoFooter = require(Plugin.Components.RojoFooter)
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
local Panel = Roact.Component:extend("Panel")
|
||||
|
||||
function Panel:init()
|
||||
self.footerSize, self.setFooterSize = Roact.createBinding(Vector2.new())
|
||||
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,
|
||||
}),
|
||||
|
||||
Body = e("Frame", {
|
||||
Size = UDim2.new(0, 360, 1, -32),
|
||||
BackgroundTransparency = 1,
|
||||
}, self.props[Roact.Children]),
|
||||
|
||||
Footer = e(RojoFooter),
|
||||
})
|
||||
end
|
||||
|
||||
return Panel
|
||||
69
plugin/src/Components/RojoFooter.lua
Normal file
@@ -0,0 +1,69 @@
|
||||
local Rojo = script:FindFirstAncestor("Rojo")
|
||||
local Plugin = Rojo.Plugin
|
||||
|
||||
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 FitText = require(Plugin.Components.FitText)
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
local RojoFooter = Roact.Component:extend("RojoFooter")
|
||||
|
||||
function RojoFooter:init()
|
||||
self.footerSize, self.setFooterSize = Roact.createBinding(Vector2.new())
|
||||
self.footerVersionSize, self.setFooterVersionSize = Roact.createBinding(Vector2.new())
|
||||
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),
|
||||
}, {
|
||||
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.LightTextColor,
|
||||
BackgroundTransparency = 1,
|
||||
|
||||
[Roact.Change.AbsoluteSize] = function(rbx)
|
||||
self.setFooterVersionSize(rbx.AbsoluteSize)
|
||||
end,
|
||||
}),
|
||||
})
|
||||
end
|
||||
|
||||
return RojoFooter
|
||||
@@ -1,6 +1,6 @@
|
||||
return {
|
||||
codename = "Epiphany",
|
||||
version = {0, 5, 0, "-alpha.1"},
|
||||
version = {0, 5, 0, "-alpha.13"},
|
||||
expectedServerVersionString = "0.5.0 or newer",
|
||||
protocolVersion = 2,
|
||||
defaultHost = "localhost",
|
||||
|
||||
@@ -1,10 +1,27 @@
|
||||
local Config = require(script.Parent.Config)
|
||||
|
||||
local Environment = {
|
||||
User = "User",
|
||||
Dev = "Dev",
|
||||
Test = "Test",
|
||||
}
|
||||
|
||||
local VALUES = {
|
||||
LogLevel = {
|
||||
type = "IntValue",
|
||||
defaultUserValue = 2,
|
||||
defaultDevValue = 3,
|
||||
values = {
|
||||
[Environment.User] = 2,
|
||||
[Environment.Dev] = 3,
|
||||
[Environment.Test] = 3,
|
||||
},
|
||||
},
|
||||
TypecheckingEnabled = {
|
||||
type = "BoolValue",
|
||||
values = {
|
||||
[Environment.User] = false,
|
||||
[Environment.Dev] = true,
|
||||
[Environment.Test] = true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -42,7 +59,9 @@ local function setStoredValue(name, kind, value)
|
||||
object.Value = value
|
||||
end
|
||||
|
||||
local function createAllValues()
|
||||
local function createAllValues(environment)
|
||||
assert(Environment[environment] ~= nil, "Invalid environment")
|
||||
|
||||
valueContainer = getValueContainer()
|
||||
|
||||
if valueContainer == nil then
|
||||
@@ -52,20 +71,57 @@ local function createAllValues()
|
||||
end
|
||||
|
||||
for name, value in pairs(VALUES) do
|
||||
setStoredValue(name, value.type, value.defaultDevValue)
|
||||
setStoredValue(name, value.type, value.values[environment])
|
||||
end
|
||||
end
|
||||
|
||||
_G[("ROJO_%s_DEV_CREATE"):format(Config.codename:upper())] = createAllValues
|
||||
local function getValue(name)
|
||||
assert(VALUES[name] ~= nil, "Invalid DevSettings name")
|
||||
|
||||
local stored = getStoredValue(name)
|
||||
|
||||
if stored ~= nil then
|
||||
return stored
|
||||
end
|
||||
|
||||
return VALUES[name].values[Environment.User]
|
||||
end
|
||||
|
||||
local DevSettings = {}
|
||||
|
||||
function DevSettings:createDevSettings()
|
||||
createAllValues(Environment.Dev)
|
||||
end
|
||||
|
||||
function DevSettings:createTestSettings()
|
||||
createAllValues(Environment.Test)
|
||||
end
|
||||
|
||||
function DevSettings:hasChangedValues()
|
||||
return valueContainer ~= nil
|
||||
end
|
||||
|
||||
function DevSettings:resetValues()
|
||||
if valueContainer then
|
||||
valueContainer:Destroy()
|
||||
valueContainer = nil
|
||||
end
|
||||
end
|
||||
|
||||
function DevSettings:isEnabled()
|
||||
return valueContainer ~= nil
|
||||
end
|
||||
|
||||
function DevSettings:getLogLevel()
|
||||
return getStoredValue("LogLevel") or VALUES.LogLevel.defaultUserValue
|
||||
return getValue("LogLevel")
|
||||
end
|
||||
|
||||
function DevSettings:shouldTypecheck()
|
||||
return getValue("TypecheckingEnabled")
|
||||
end
|
||||
|
||||
function _G.ROJO_DEV_CREATE()
|
||||
DevSettings:createDevSettings()
|
||||
end
|
||||
|
||||
return DevSettings
|
||||
81
plugin/src/InstanceMap.lua
Normal file
@@ -0,0 +1,81 @@
|
||||
local Logging = require(script.Parent.Logging)
|
||||
|
||||
--[[
|
||||
A bidirectional map between instance IDs and Roblox instances. It lets us
|
||||
keep track of every instance we know about.
|
||||
|
||||
TODO: Track ancestry to catch when stuff moves?
|
||||
]]
|
||||
local InstanceMap = {}
|
||||
InstanceMap.__index = InstanceMap
|
||||
|
||||
function InstanceMap.new()
|
||||
local self = {
|
||||
fromIds = {},
|
||||
fromInstances = {},
|
||||
}
|
||||
|
||||
return setmetatable(self, InstanceMap)
|
||||
end
|
||||
|
||||
function InstanceMap:insert(id, instance)
|
||||
self.fromIds[id] = instance
|
||||
self.fromInstances[instance] = id
|
||||
end
|
||||
|
||||
function InstanceMap:removeId(id)
|
||||
local instance = self.fromIds[id]
|
||||
|
||||
if instance ~= nil then
|
||||
self.fromIds[id] = nil
|
||||
self.fromInstances[instance] = nil
|
||||
else
|
||||
Logging.warn("Attempted to remove nonexistant ID %s", tostring(id))
|
||||
end
|
||||
end
|
||||
|
||||
function InstanceMap:removeInstance(instance)
|
||||
local id = self.fromInstances[instance]
|
||||
|
||||
if id ~= nil then
|
||||
self.fromInstances[instance] = nil
|
||||
self.fromIds[id] = nil
|
||||
else
|
||||
Logging.warn("Attempted to remove nonexistant instance %s", tostring(instance))
|
||||
end
|
||||
end
|
||||
|
||||
function InstanceMap:destroyInstance(instance)
|
||||
local id = self.fromInstances[instance]
|
||||
|
||||
if id ~= nil then
|
||||
self:destroyId(id)
|
||||
else
|
||||
Logging.warn("Attempted to destroy untracked instance %s", tostring(instance))
|
||||
end
|
||||
end
|
||||
|
||||
function InstanceMap:destroyId(id)
|
||||
local instance = self.fromIds[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)
|
||||
end
|
||||
|
||||
instance:Destroy()
|
||||
else
|
||||
Logging.warn("Attempted to destroy nonexistant ID %s", tostring(id))
|
||||
end
|
||||
end
|
||||
|
||||
return InstanceMap
|
||||
@@ -1,100 +1,15 @@
|
||||
local t = require(script.Parent.Parent.t)
|
||||
|
||||
local InstanceMap = require(script.Parent.InstanceMap)
|
||||
local Logging = require(script.Parent.Logging)
|
||||
local setCanonicalProperty = require(script.Parent.setCanonicalProperty)
|
||||
local rojoValueToRobloxValue = require(script.Parent.rojoValueToRobloxValue)
|
||||
local Types = require(script.Parent.Types)
|
||||
|
||||
local function makeInstanceMap()
|
||||
local self = {
|
||||
fromIds = {},
|
||||
fromInstances = {},
|
||||
}
|
||||
|
||||
function self:insert(id, instance)
|
||||
self.fromIds[id] = instance
|
||||
self.fromInstances[instance] = id
|
||||
end
|
||||
|
||||
function self:removeId(id)
|
||||
local instance = self.fromIds[id]
|
||||
|
||||
if instance ~= nil then
|
||||
self.fromIds[id] = nil
|
||||
self.fromInstances[instance] = nil
|
||||
else
|
||||
Logging.warn("Attempted to remove nonexistant ID %s", tostring(id))
|
||||
end
|
||||
end
|
||||
|
||||
function self:removeInstance(instance)
|
||||
local id = self.fromInstances[instance]
|
||||
|
||||
if id ~= nil then
|
||||
self.fromInstances[instance] = nil
|
||||
self.fromIds[id] = nil
|
||||
else
|
||||
Logging.warn("Attempted to remove nonexistant instance %s", tostring(instance))
|
||||
end
|
||||
end
|
||||
|
||||
function self:destroyId(id)
|
||||
local instance = self.fromIds[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)
|
||||
end
|
||||
|
||||
instance:Destroy()
|
||||
else
|
||||
Logging.warn("Attempted to destroy nonexistant ID %s", tostring(id))
|
||||
end
|
||||
end
|
||||
|
||||
return self
|
||||
end
|
||||
|
||||
local function setProperty(instance, key, value)
|
||||
-- The 'Contents' property of LocalizationTable isn't directly exposed, but
|
||||
-- has corresponding (deprecated) getters and setters.
|
||||
if key == "Contents" and instance.ClassName == "LocalizationTable" then
|
||||
instance:SetContents(value)
|
||||
return
|
||||
end
|
||||
|
||||
-- If we don't have permissions to access this value at all, we can skip it.
|
||||
local readSuccess, existingValue = pcall(function()
|
||||
return instance[key]
|
||||
local function setParent(instance, newParent)
|
||||
pcall(function()
|
||||
instance.Parent = newParent
|
||||
end)
|
||||
|
||||
if not readSuccess then
|
||||
-- An error will be thrown if there was a permission issue or if the
|
||||
-- property doesn't exist. In the latter case, we should tell the user
|
||||
-- because it's probably their fault.
|
||||
if existingValue:find("lacking permission") then
|
||||
Logging.trace("Permission error reading property %s on class %s", tostring(key), instance.ClassName)
|
||||
return
|
||||
else
|
||||
error(("Invalid property %s on class %s: %s"):format(tostring(key), instance.ClassName, existingValue), 2)
|
||||
end
|
||||
end
|
||||
|
||||
local writeSuccess, err = pcall(function()
|
||||
if existingValue ~= value then
|
||||
instance[key] = value
|
||||
end
|
||||
end)
|
||||
|
||||
if not writeSuccess then
|
||||
error(("Cannot set property %s on class %s: %s"):format(tostring(key), instance.ClassName, err), 2)
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
local Reconciler = {}
|
||||
@@ -102,7 +17,7 @@ Reconciler.__index = Reconciler
|
||||
|
||||
function Reconciler.new()
|
||||
local self = {
|
||||
instanceMap = makeInstanceMap(),
|
||||
instanceMap = InstanceMap.new(),
|
||||
}
|
||||
|
||||
return setmetatable(self, Reconciler)
|
||||
@@ -118,11 +33,18 @@ function Reconciler:applyUpdate(requestedIds, virtualInstancesById)
|
||||
end
|
||||
end
|
||||
|
||||
local reconcileSchema = Types.ifEnabled(t.tuple(
|
||||
t.map(t.string, Types.VirtualInstance),
|
||||
t.string,
|
||||
t.Instance
|
||||
))
|
||||
--[[
|
||||
Update an existing instance, including its properties and children, to match
|
||||
the given information.
|
||||
]]
|
||||
function Reconciler:reconcile(virtualInstancesById, id, instance)
|
||||
assert(reconcileSchema(virtualInstancesById, id, instance))
|
||||
|
||||
local virtualInstance = virtualInstancesById[id]
|
||||
|
||||
-- If an instance changes ClassName, we assume it's very different. That's
|
||||
@@ -137,10 +59,10 @@ function Reconciler:reconcile(virtualInstancesById, id, instance)
|
||||
self.instanceMap:insert(id, instance)
|
||||
|
||||
-- Some instances don't like being named, even if their name already matches
|
||||
setProperty(instance, "Name", virtualInstance.Name)
|
||||
setCanonicalProperty(instance, "Name", virtualInstance.Name)
|
||||
|
||||
for key, value in pairs(virtualInstance.Properties) do
|
||||
setProperty(instance, key, value.Value)
|
||||
setCanonicalProperty(instance, key, rojoValueToRobloxValue(value))
|
||||
end
|
||||
|
||||
local existingChildren = instance:GetChildren()
|
||||
@@ -175,10 +97,17 @@ function Reconciler:reconcile(virtualInstancesById, id, instance)
|
||||
end
|
||||
end
|
||||
|
||||
if self:__shouldClearUnknownInstances(virtualInstance) then
|
||||
for existingChildInstance in pairs(unvisitedExistingChildren) do
|
||||
self.instanceMap:removeInstance(existingChildInstance)
|
||||
existingChildInstance:Destroy()
|
||||
local shouldClearUnknown = self:__shouldClearUnknownChildren(virtualInstance)
|
||||
|
||||
for existingChildInstance in pairs(unvisitedExistingChildren) do
|
||||
local childId = self.instanceMap.fromInstances[existingChildInstance]
|
||||
|
||||
if childId == nil then
|
||||
if shouldClearUnknown then
|
||||
existingChildInstance:Destroy()
|
||||
end
|
||||
else
|
||||
self.instanceMap:destroyInstance(existingChildInstance)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -194,16 +123,13 @@ function Reconciler:reconcile(virtualInstancesById, id, instance)
|
||||
|
||||
-- Some instances, like services, don't like having their Parent
|
||||
-- property poked, even if we're setting it to the same value.
|
||||
setProperty(instance, "Parent", parent)
|
||||
if instance.Parent ~= parent then
|
||||
instance.Parent = parent
|
||||
end
|
||||
setParent(instance, parent)
|
||||
end
|
||||
|
||||
return instance
|
||||
end
|
||||
|
||||
function Reconciler:__shouldClearUnknownInstances(virtualInstance)
|
||||
function Reconciler:__shouldClearUnknownChildren(virtualInstance)
|
||||
if virtualInstance.Metadata ~= nil then
|
||||
return not virtualInstance.Metadata.ignoreUnknownInstances
|
||||
else
|
||||
@@ -211,29 +137,44 @@ function Reconciler:__shouldClearUnknownInstances(virtualInstance)
|
||||
end
|
||||
end
|
||||
|
||||
local reifySchema = Types.ifEnabled(t.tuple(
|
||||
t.map(t.string, Types.VirtualInstance),
|
||||
t.string,
|
||||
t.Instance
|
||||
))
|
||||
|
||||
function Reconciler:__reify(virtualInstancesById, id, parent)
|
||||
assert(reifySchema(virtualInstancesById, id, parent))
|
||||
|
||||
local virtualInstance = virtualInstancesById[id]
|
||||
|
||||
local instance = Instance.new(virtualInstance.ClassName)
|
||||
|
||||
for key, value in pairs(virtualInstance.Properties) do
|
||||
-- TODO: Branch on value.Type
|
||||
setProperty(instance, key, value.Value)
|
||||
setCanonicalProperty(instance, key, rojoValueToRobloxValue(value))
|
||||
end
|
||||
|
||||
instance.Name = virtualInstance.Name
|
||||
setCanonicalProperty(instance, "Name", virtualInstance.Name)
|
||||
|
||||
for _, childId in ipairs(virtualInstance.Children) do
|
||||
self:__reify(virtualInstancesById, childId, instance)
|
||||
end
|
||||
|
||||
setProperty(instance, "Parent", parent)
|
||||
setParent(instance, parent)
|
||||
self.instanceMap:insert(id, instance)
|
||||
|
||||
return instance
|
||||
end
|
||||
|
||||
local applyUpdatePieceSchema = Types.ifEnabled(t.tuple(
|
||||
t.string,
|
||||
t.map(t.string, t.boolean),
|
||||
t.map(t.string, Types.VirtualInstance)
|
||||
))
|
||||
|
||||
function Reconciler:__applyUpdatePiece(id, visitedIds, virtualInstancesById)
|
||||
assert(applyUpdatePieceSchema(id, visitedIds, virtualInstancesById))
|
||||
|
||||
if visitedIds[id] then
|
||||
return
|
||||
end
|
||||
|
||||
218
plugin/src/Reconciler.spec.lua
Normal file
@@ -0,0 +1,218 @@
|
||||
local Reconciler = require(script.Parent.Reconciler)
|
||||
|
||||
return function()
|
||||
it("should leave instances alone if there's nothing specified", function()
|
||||
local instance = Instance.new("Folder")
|
||||
instance.Name = "TestFolder"
|
||||
|
||||
local instanceId = "test-id"
|
||||
local virtualInstancesById = {
|
||||
[instanceId] = {
|
||||
Name = "TestFolder",
|
||||
ClassName = "Folder",
|
||||
Children = {},
|
||||
Properties = {},
|
||||
},
|
||||
}
|
||||
|
||||
local reconciler = Reconciler.new()
|
||||
reconciler:reconcile(virtualInstancesById, instanceId, instance)
|
||||
end)
|
||||
|
||||
it("should assign names from virtual instances", function()
|
||||
local instance = Instance.new("Folder")
|
||||
instance.Name = "InitialName"
|
||||
|
||||
local instanceId = "test-id"
|
||||
local virtualInstancesById = {
|
||||
[instanceId] = {
|
||||
Name = "NewName",
|
||||
ClassName = "Folder",
|
||||
Children = {},
|
||||
Properties = {},
|
||||
},
|
||||
}
|
||||
|
||||
local reconciler = Reconciler.new()
|
||||
reconciler:reconcile(virtualInstancesById, instanceId, instance)
|
||||
|
||||
expect(instance.Name).to.equal("NewName")
|
||||
end)
|
||||
|
||||
it("should assign properties from virtual instances", function()
|
||||
local instance = Instance.new("IntValue")
|
||||
instance.Name = "TestValue"
|
||||
instance.Value = 5
|
||||
|
||||
local instanceId = "test-id"
|
||||
local virtualInstancesById = {
|
||||
[instanceId] = {
|
||||
Name = "TestValue",
|
||||
ClassName = "IntValue",
|
||||
Children = {},
|
||||
Properties = {
|
||||
Value = {
|
||||
Type = "Int32",
|
||||
Value = 9
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
local reconciler = Reconciler.new()
|
||||
reconciler:reconcile(virtualInstancesById, instanceId, instance)
|
||||
|
||||
expect(instance.Value).to.equal(9)
|
||||
end)
|
||||
|
||||
it("should wipe unknown children by default", function()
|
||||
local parent = Instance.new("Folder")
|
||||
parent.Name = "Parent"
|
||||
|
||||
local child = Instance.new("Folder")
|
||||
child.Name = "Child"
|
||||
|
||||
local parentId = "test-id"
|
||||
local virtualInstancesById = {
|
||||
[parentId] = {
|
||||
Name = "Parent",
|
||||
ClassName = "Folder",
|
||||
Children = {},
|
||||
Properties = {},
|
||||
},
|
||||
}
|
||||
|
||||
local reconciler = Reconciler.new()
|
||||
reconciler:reconcile(virtualInstancesById, parentId, parent)
|
||||
|
||||
expect(#parent:GetChildren()).to.equal(0)
|
||||
end)
|
||||
|
||||
it("should preserve unknown children if ignoreUnknownInstances is set", function()
|
||||
local parent = Instance.new("Folder")
|
||||
parent.Name = "Parent"
|
||||
|
||||
local child = Instance.new("Folder")
|
||||
child.Parent = parent
|
||||
child.Name = "Child"
|
||||
|
||||
local parentId = "test-id"
|
||||
local virtualInstancesById = {
|
||||
[parentId] = {
|
||||
Name = "Parent",
|
||||
ClassName = "Folder",
|
||||
Children = {},
|
||||
Properties = {},
|
||||
Metadata = {
|
||||
ignoreUnknownInstances = true,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
local reconciler = Reconciler.new()
|
||||
reconciler:reconcile(virtualInstancesById, parentId, parent)
|
||||
|
||||
expect(child.Parent).to.equal(parent)
|
||||
expect(#parent:GetChildren()).to.equal(1)
|
||||
end)
|
||||
|
||||
it("should remove known removed children", function()
|
||||
local parent = Instance.new("Folder")
|
||||
parent.Name = "Parent"
|
||||
|
||||
local child = Instance.new("Folder")
|
||||
child.Parent = parent
|
||||
child.Name = "Child"
|
||||
|
||||
local parentId = "parent-id"
|
||||
local childId = "child-id"
|
||||
|
||||
local reconciler = Reconciler.new()
|
||||
|
||||
local virtualInstancesById = {
|
||||
[parentId] = {
|
||||
Name = "Parent",
|
||||
ClassName = "Folder",
|
||||
Children = {childId},
|
||||
Properties = {},
|
||||
},
|
||||
[childId] = {
|
||||
Name = "Child",
|
||||
ClassName = "Folder",
|
||||
Children = {},
|
||||
Properties = {},
|
||||
},
|
||||
}
|
||||
reconciler:reconcile(virtualInstancesById, parentId, parent)
|
||||
|
||||
expect(child.Parent).to.equal(parent)
|
||||
expect(#parent:GetChildren()).to.equal(1)
|
||||
|
||||
local newVirtualInstances = {
|
||||
[parentId] = {
|
||||
Name = "Parent",
|
||||
ClassName = "Folder",
|
||||
Children = {},
|
||||
Properties = {},
|
||||
},
|
||||
[childId] = nil,
|
||||
}
|
||||
reconciler:reconcile(newVirtualInstances, parentId, parent)
|
||||
|
||||
expect(child.Parent).to.equal(nil)
|
||||
expect(#parent:GetChildren()).to.equal(0)
|
||||
end)
|
||||
|
||||
it("should remove known removed children if ignoreUnknownInstances is set", function()
|
||||
local parent = Instance.new("Folder")
|
||||
parent.Name = "Parent"
|
||||
|
||||
local child = Instance.new("Folder")
|
||||
child.Parent = parent
|
||||
child.Name = "Child"
|
||||
|
||||
local parentId = "parent-id"
|
||||
local childId = "child-id"
|
||||
|
||||
local reconciler = Reconciler.new()
|
||||
|
||||
local virtualInstancesById = {
|
||||
[parentId] = {
|
||||
Name = "Parent",
|
||||
ClassName = "Folder",
|
||||
Children = {childId},
|
||||
Properties = {},
|
||||
Metadata = {
|
||||
ignoreUnknownInstances = true,
|
||||
},
|
||||
},
|
||||
[childId] = {
|
||||
Name = "Child",
|
||||
ClassName = "Folder",
|
||||
Children = {},
|
||||
Properties = {},
|
||||
},
|
||||
}
|
||||
reconciler:reconcile(virtualInstancesById, parentId, parent)
|
||||
|
||||
expect(child.Parent).to.equal(parent)
|
||||
expect(#parent:GetChildren()).to.equal(1)
|
||||
|
||||
local newVirtualInstances = {
|
||||
[parentId] = {
|
||||
Name = "Parent",
|
||||
ClassName = "Folder",
|
||||
Children = {},
|
||||
Properties = {},
|
||||
Metadata = {
|
||||
ignoreUnknownInstances = true,
|
||||
},
|
||||
},
|
||||
[childId] = nil,
|
||||
}
|
||||
reconciler:reconcile(newVirtualInstances, parentId, parent)
|
||||
|
||||
expect(child.Parent).to.equal(nil)
|
||||
expect(#parent:GetChildren()).to.equal(0)
|
||||
end)
|
||||
end
|
||||
@@ -4,11 +4,11 @@ local Theme = {
|
||||
TitleFont = Enum.Font.GothamBold,
|
||||
MainFont = Enum.Font.Gotham,
|
||||
|
||||
AccentColor = Color3.fromRGB(136, 0, 27),
|
||||
AccentLightColor = Color3.fromRGB(210, 145, 157),
|
||||
PrimaryColor = Color3.fromRGB(20, 20, 20),
|
||||
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(140, 140, 140),
|
||||
LightTextColor = Color3.fromRGB(160, 160, 160),
|
||||
}
|
||||
|
||||
setmetatable(Theme, {
|
||||
|
||||
36
plugin/src/Types.lua
Normal file
@@ -0,0 +1,36 @@
|
||||
local t = require(script.Parent.Parent.t)
|
||||
|
||||
local DevSettings = require(script.Parent.DevSettings)
|
||||
|
||||
local VirtualValue = t.interface({
|
||||
Type = t.string,
|
||||
Value = t.optional(t.any),
|
||||
})
|
||||
|
||||
local VirtualMetadata = t.interface({
|
||||
ignoreUnknownInstances = t.optional(t.boolean),
|
||||
})
|
||||
|
||||
local VirtualInstance = t.interface({
|
||||
Name = t.string,
|
||||
ClassName = t.string,
|
||||
Properties = t.map(t.string, VirtualValue),
|
||||
Metadata = t.optional(VirtualMetadata)
|
||||
})
|
||||
|
||||
local function ifEnabled(innerCheck)
|
||||
return function(...)
|
||||
if DevSettings:shouldTypecheck() then
|
||||
return innerCheck(...)
|
||||
else
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return {
|
||||
ifEnabled = ifEnabled,
|
||||
VirtualInstance = VirtualInstance,
|
||||
VirtualMetadata = VirtualMetadata,
|
||||
VirtualValue = VirtualValue,
|
||||
}
|
||||
@@ -4,16 +4,14 @@ end
|
||||
|
||||
local Roact = require(script.Parent.Roact)
|
||||
|
||||
Roact.setGlobalConfig({
|
||||
elementTracing = true,
|
||||
})
|
||||
|
||||
local App = require(script.Components.App)
|
||||
|
||||
local app = Roact.createElement(App, {
|
||||
plugin = plugin,
|
||||
})
|
||||
|
||||
Roact.mount(app, game:GetService("CoreGui"), "Rojo UI")
|
||||
local tree = Roact.mount(app, game:GetService("CoreGui"), "Rojo UI")
|
||||
|
||||
-- TODO: Detect another instance of Rojo coming online and shut down this one.
|
||||
plugin.Unloading:Connect(function()
|
||||
Roact.unmount(tree)
|
||||
end)
|
||||
@@ -1,34 +0,0 @@
|
||||
--[[
|
||||
joinBindings is a crazy hack that allows combining multiple Roact bindings
|
||||
in the same spirit as `map`.
|
||||
|
||||
It's implemented in terms of Roact internals that will probably break at
|
||||
some point; please don't do that or use this module in your own code!
|
||||
]]
|
||||
|
||||
local Binding = require(script:FindFirstAncestor("Rojo").Roact.Binding)
|
||||
|
||||
local function evaluate(fun, bindings)
|
||||
local input = {}
|
||||
|
||||
for index, binding in ipairs(bindings) do
|
||||
input[index] = binding:getValue()
|
||||
end
|
||||
|
||||
return fun(unpack(input, 1, #bindings))
|
||||
end
|
||||
|
||||
local function joinBindings(bindings, joinFunction)
|
||||
local initialValue = evaluate(joinFunction, bindings)
|
||||
local binding, setValue = Binding.create(initialValue)
|
||||
|
||||
for _, binding in ipairs(bindings) do
|
||||
Binding.subscribe(binding, function()
|
||||
setValue(evaluate(joinFunction, bindings))
|
||||
end)
|
||||
end
|
||||
|
||||
return binding
|
||||
end
|
||||
|
||||
return joinBindings
|
||||
28
plugin/src/preloadAssets.lua
Normal file
@@ -0,0 +1,28 @@
|
||||
local ContentProvider = game:GetService("ContentProvider")
|
||||
|
||||
local Logging = require(script.Parent.Logging)
|
||||
local Assets = require(script.Parent.Assets)
|
||||
|
||||
local function preloadAssets()
|
||||
local contentUrls = {}
|
||||
|
||||
for _, sprite in pairs(Assets.Sprites) do
|
||||
table.insert(contentUrls, sprite.asset)
|
||||
end
|
||||
|
||||
for _, slice in pairs(Assets.Slices) do
|
||||
table.insert(contentUrls, slice.asset)
|
||||
end
|
||||
|
||||
for _, url in pairs(Assets.Images) do
|
||||
table.insert(contentUrls, url)
|
||||
end
|
||||
|
||||
Logging.trace("Preloading assets: %s", table.concat(contentUrls, ", "))
|
||||
|
||||
coroutine.wrap(function()
|
||||
ContentProvider:PreloadAsync(contentUrls)
|
||||
end)()
|
||||
end
|
||||
|
||||
return preloadAssets
|
||||
19
plugin/src/rojoValueToRobloxValue.lua
Normal file
@@ -0,0 +1,19 @@
|
||||
local RbxDom = require(script:FindFirstAncestor("Rojo").RbxDom)
|
||||
|
||||
local function rojoValueToRobloxValue(value)
|
||||
-- TODO: Manually decode this value by looking up its GUID The Rojo server
|
||||
-- doesn't give us valid ref values yet, so this isn't important yet.
|
||||
if value.Type == "Ref" then
|
||||
return nil
|
||||
end
|
||||
|
||||
local success, decodedValue = RbxDom.EncodedValue.decode(value)
|
||||
|
||||
if not success then
|
||||
error(decodedValue, 2)
|
||||
end
|
||||
|
||||
return decodedValue
|
||||
end
|
||||
|
||||
return rojoValueToRobloxValue
|
||||
40
plugin/src/rojoValueToRobloxValue.spec.lua
Normal file
@@ -0,0 +1,40 @@
|
||||
local rojoValueToRobloxValue = require(script.Parent.rojoValueToRobloxValue)
|
||||
|
||||
return function()
|
||||
it("should convert primitives", function()
|
||||
local inputString = {
|
||||
Type = "String",
|
||||
Value = "Hello, world!",
|
||||
}
|
||||
|
||||
local inputFloat32 = {
|
||||
Type = "Float32",
|
||||
Value = 12341.512,
|
||||
}
|
||||
|
||||
expect(rojoValueToRobloxValue(inputString)).to.equal(inputString.Value)
|
||||
expect(rojoValueToRobloxValue(inputFloat32)).to.equal(inputFloat32.Value)
|
||||
end)
|
||||
|
||||
it("should convert properties with direct constructors", function()
|
||||
local inputColor3 = {
|
||||
Type = "Color3",
|
||||
Value = {0, 1, 0.5},
|
||||
}
|
||||
local outputColor3 = Color3.new(0, 1, 0.5)
|
||||
|
||||
local inputCFrame = {
|
||||
Type = "CFrame",
|
||||
Value = {
|
||||
1, 2, 3,
|
||||
4, 5, 6,
|
||||
7, 8, 9,
|
||||
10, 11, 12,
|
||||
},
|
||||
}
|
||||
local outputCFrame = CFrame.new(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12)
|
||||
|
||||
expect(rojoValueToRobloxValue(inputColor3)).to.equal(outputColor3)
|
||||
expect(rojoValueToRobloxValue(inputCFrame)).to.equal(outputCFrame)
|
||||
end)
|
||||
end
|
||||
37
plugin/src/setCanonicalProperty.lua
Normal file
@@ -0,0 +1,37 @@
|
||||
local RbxDom = require(script:FindFirstAncestor("Rojo").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
|
||||
@@ -1,2 +1,19 @@
|
||||
local TestEZ = require(game.ReplicatedStorage.TestEZ)
|
||||
TestEZ.TestBootstrap:run(game.ReplicatedStorage.Rojo.plugin)
|
||||
local ReplicatedStorage = game:GetService("ReplicatedStorage")
|
||||
|
||||
local TestEZ = require(ReplicatedStorage.TestEZ)
|
||||
|
||||
local Rojo = ReplicatedStorage.Rojo
|
||||
|
||||
local DevSettings = require(Rojo.Plugin.DevSettings)
|
||||
|
||||
local setDevSettings = not DevSettings:hasChangedValues()
|
||||
|
||||
if setDevSettings then
|
||||
DevSettings:createTestSettings()
|
||||
end
|
||||
|
||||
TestEZ.TestBootstrap:run({Rojo.Plugin})
|
||||
|
||||
if setDevSettings then
|
||||
DevSettings:resetValues()
|
||||
end
|
||||
@@ -1,6 +0,0 @@
|
||||
[package]
|
||||
name = "rojo-e2e"
|
||||
version = "0.1.0"
|
||||
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
|
||||
|
||||
[dependencies]
|
||||
@@ -1,2 +0,0 @@
|
||||
# Rojo End-to-End
|
||||
This is a WIP test runner designed for Rojo. It will eventually start up the Rojo server and plugin and test functionality end-to-end.
|
||||
@@ -1,32 +0,0 @@
|
||||
use std::{
|
||||
path::Path,
|
||||
process::Command,
|
||||
thread,
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
fn main() {
|
||||
let plugin_path = Path::new("../plugin");
|
||||
let server_path = Path::new("../server");
|
||||
let tests_path = Path::new("../tests");
|
||||
|
||||
let server = Command::new("cargo")
|
||||
.args(&["run", "--", "serve", "../test-projects/empty"])
|
||||
.current_dir(server_path)
|
||||
.spawn();
|
||||
|
||||
thread::sleep(Duration::from_millis(1000));
|
||||
|
||||
// TODO: Wait for server to start responding on the right port
|
||||
|
||||
let test_client = Command::new("lua")
|
||||
.args(&["runTest.lua", "tests/empty.lua"])
|
||||
.current_dir(plugin_path)
|
||||
.spawn();
|
||||
|
||||
thread::sleep(Duration::from_millis(300));
|
||||
|
||||
// TODO: Collect output from the client for success/failure?
|
||||
|
||||
println!("Dying!");
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
[package]
|
||||
name = "rojo"
|
||||
version = "0.5.0-alpha.1"
|
||||
version = "0.5.0-alpha.13"
|
||||
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
|
||||
description = "A tool to create robust Roblox projects"
|
||||
description = "Enables professional-grade development tools for Roblox developers"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/LPGhatguy/rojo"
|
||||
repository = "https://github.com/rojo-rbx/rojo"
|
||||
edition = "2018"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
server-plugins = []
|
||||
|
||||
[lib]
|
||||
name = "librojo"
|
||||
path = "src/lib.rs"
|
||||
@@ -15,31 +19,31 @@ path = "src/lib.rs"
|
||||
name = "rojo"
|
||||
path = "src/bin.rs"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
bundle-plugin = []
|
||||
|
||||
[dependencies]
|
||||
clap = "2.27"
|
||||
csv = "1.0"
|
||||
env_logger = "0.5"
|
||||
env_logger = "0.6"
|
||||
failure = "0.1.3"
|
||||
futures = "0.1"
|
||||
hyper = "0.12"
|
||||
log = "0.4"
|
||||
maplit = "1.0.1"
|
||||
notify = "4.0"
|
||||
rand = "0.4"
|
||||
rbx_binary = "0.4.1"
|
||||
rbx_dom_weak = "1.9.0"
|
||||
rbx_xml = "0.11.0"
|
||||
rbx_reflection = "3.1.388"
|
||||
regex = "1.0"
|
||||
reqwest = "0.9.5"
|
||||
rouille = "2.1"
|
||||
serde = "1.0"
|
||||
serde_derive = "1.0"
|
||||
rlua = "0.16"
|
||||
ritz = "0.1.0"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
uuid = { version = "0.7", features = ["v4", "serde"] }
|
||||
rbx_tree = "0.2.0"
|
||||
rbx_xml = "0.2.0"
|
||||
rbx_binary = "0.2.0"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.0"
|
||||
walkdir = "2.1"
|
||||
lazy_static = "1.2"
|
||||
lazy_static = "1.2"
|
||||
pretty_assertions = "0.6.1"
|
||||
paste = "0.1"
|
||||
@@ -1,4 +0,0 @@
|
||||
# Rojo Server
|
||||
This is the source to the Rojo server.
|
||||
|
||||
Documentation is WIP.
|
||||
43
server/assets/index.css
Normal file
@@ -0,0 +1,43 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: sans-serif;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.main {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
max-width: 60rem;
|
||||
background-color: #efefef;
|
||||
border: 1px solid #666;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.docs {
|
||||
font-size: 1.3rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
66
server/assets/place.project.json
Normal file
@@ -0,0 +1,66 @@
|
||||
{
|
||||
"name": "[placeholder]",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"SoundService": {
|
||||
"$className": "SoundService",
|
||||
"$properties": {
|
||||
"RespectFilteringEnabled": true
|
||||
}
|
||||
},
|
||||
"Workspace": {
|
||||
"$className": "Workspace",
|
||||
"$properties": {
|
||||
"FilteringEnabled": true
|
||||
},
|
||||
"Baseplate": {
|
||||
"$className": "Part",
|
||||
"$properties": {
|
||||
"Anchored": true,
|
||||
"Color": [
|
||||
0.38823,
|
||||
0.37254,
|
||||
0.38823
|
||||
],
|
||||
"Locked": true,
|
||||
"Position": [
|
||||
0,
|
||||
-10,
|
||||
0
|
||||
],
|
||||
"Size": [
|
||||
512,
|
||||
20,
|
||||
512
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,14 @@ fn make_path_absolute(value: &Path) -> PathBuf {
|
||||
}
|
||||
|
||||
fn main() {
|
||||
env_logger::Builder::from_default_env()
|
||||
.default_format_timestamp(false)
|
||||
.init();
|
||||
{
|
||||
let log_env = env_logger::Env::default()
|
||||
.default_filter_or("warn");
|
||||
|
||||
env_logger::Builder::from_env(log_env)
|
||||
.default_format_timestamp(false)
|
||||
.init();
|
||||
}
|
||||
|
||||
let app = clap_app!(Rojo =>
|
||||
(version: env!("CARGO_PKG_VERSION"))
|
||||
@@ -38,7 +43,7 @@ fn main() {
|
||||
(@subcommand serve =>
|
||||
(about: "Serves the project's files for use with the Rojo Studio plugin.")
|
||||
(@arg PROJECT: "Path to the project to serve. Defaults to the current directory.")
|
||||
(@arg port: --port +takes_value "The port to listen on. Defaults to 8000.")
|
||||
(@arg port: --port +takes_value "The port to listen on. Defaults to 34872.")
|
||||
)
|
||||
|
||||
(@subcommand build =>
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
fs::File,
|
||||
io,
|
||||
io::{self, Write, BufWriter},
|
||||
};
|
||||
|
||||
use log::info;
|
||||
use failure::Fail;
|
||||
|
||||
use crate::{
|
||||
imfs::{Imfs, FsError},
|
||||
project::{Project, ProjectLoadError},
|
||||
rbx_session::construct_oneoff_tree,
|
||||
project::{Project, ProjectLoadFuzzyError},
|
||||
imfs::Imfs,
|
||||
rbx_snapshot::SnapshotError,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -46,7 +47,7 @@ pub enum BuildError {
|
||||
UnknownOutputKind,
|
||||
|
||||
#[fail(display = "Project load error: {}", _0)]
|
||||
ProjectLoadError(#[fail(cause)] ProjectLoadFuzzyError),
|
||||
ProjectLoadError(#[fail(cause)] ProjectLoadError),
|
||||
|
||||
#[fail(display = "IO error: {}", _0)]
|
||||
IoError(#[fail(cause)] io::Error),
|
||||
@@ -55,16 +56,29 @@ pub enum BuildError {
|
||||
XmlModelEncodeError(rbx_xml::EncodeError),
|
||||
|
||||
#[fail(display = "Binary model file error")]
|
||||
BinaryModelEncodeError(rbx_binary::EncodeError)
|
||||
BinaryModelEncodeError(rbx_binary::EncodeError),
|
||||
|
||||
#[fail(display = "{}", _0)]
|
||||
FsError(#[fail(cause)] FsError),
|
||||
|
||||
#[fail(display = "{}", _0)]
|
||||
SnapshotError(#[fail(cause)] SnapshotError),
|
||||
}
|
||||
|
||||
impl_from!(BuildError {
|
||||
ProjectLoadFuzzyError => ProjectLoadError,
|
||||
ProjectLoadError => ProjectLoadError,
|
||||
io::Error => IoError,
|
||||
rbx_xml::EncodeError => XmlModelEncodeError,
|
||||
rbx_binary::EncodeError => BinaryModelEncodeError
|
||||
rbx_binary::EncodeError => BinaryModelEncodeError,
|
||||
FsError => FsError,
|
||||
SnapshotError => SnapshotError,
|
||||
});
|
||||
|
||||
fn xml_encode_config() -> rbx_xml::EncodeOptions {
|
||||
rbx_xml::EncodeOptions::new()
|
||||
.property_behavior(rbx_xml::EncodePropertyBehavior::WriteUnknown)
|
||||
}
|
||||
|
||||
pub fn build(options: &BuildOptions) -> Result<(), BuildError> {
|
||||
let output_kind = options.output_kind
|
||||
.or_else(|| detect_output_kind(options))
|
||||
@@ -81,8 +95,8 @@ pub fn build(options: &BuildOptions) -> Result<(), BuildError> {
|
||||
|
||||
let mut imfs = Imfs::new();
|
||||
imfs.add_roots_from_project(&project)?;
|
||||
let tree = construct_oneoff_tree(&project, &imfs);
|
||||
let mut file = File::create(&options.output_file)?;
|
||||
let tree = construct_oneoff_tree(&project, &imfs)?;
|
||||
let mut file = BufWriter::new(File::create(&options.output_file)?);
|
||||
|
||||
match output_kind {
|
||||
OutputKind::Rbxmx => {
|
||||
@@ -90,7 +104,7 @@ pub fn build(options: &BuildOptions) -> Result<(), BuildError> {
|
||||
// descendants.
|
||||
|
||||
let root_id = tree.get_root_id();
|
||||
rbx_xml::encode(&tree, &[root_id], &mut file)?;
|
||||
rbx_xml::to_writer(&mut file, &tree, &[root_id], xml_encode_config())?;
|
||||
},
|
||||
OutputKind::Rbxlx => {
|
||||
// Place files don't contain an entry for the DataModel, but our
|
||||
@@ -98,18 +112,24 @@ pub fn build(options: &BuildOptions) -> Result<(), BuildError> {
|
||||
|
||||
let root_id = tree.get_root_id();
|
||||
let top_level_ids = tree.get_instance(root_id).unwrap().get_children_ids();
|
||||
rbx_xml::encode(&tree, top_level_ids, &mut file)?;
|
||||
rbx_xml::to_writer(&mut file, &tree, top_level_ids, xml_encode_config())?;
|
||||
},
|
||||
OutputKind::Rbxm => {
|
||||
let root_id = tree.get_root_id();
|
||||
rbx_binary::encode(&tree, &[root_id], &mut file)?;
|
||||
},
|
||||
OutputKind::Rbxl => {
|
||||
log::warn!("Support for building binary places (rbxl) is still experimental.");
|
||||
log::warn!("Using the XML place format (rbxlx) is recommended instead.");
|
||||
log::warn!("For more info, see https://github.com/LPGhatguy/rojo/issues/180");
|
||||
|
||||
let root_id = tree.get_root_id();
|
||||
let top_level_ids = tree.get_instance(root_id).unwrap().get_children_ids();
|
||||
rbx_binary::encode(&tree, top_level_ids, &mut file)?;
|
||||
},
|
||||
}
|
||||
|
||||
file.flush()?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -7,9 +7,10 @@ use log::info;
|
||||
use failure::Fail;
|
||||
|
||||
use crate::{
|
||||
project::{Project, ProjectLoadFuzzyError},
|
||||
web::Server,
|
||||
live_session::LiveSession,
|
||||
project::{Project, ProjectLoadError},
|
||||
web::LiveServer,
|
||||
imfs::FsError,
|
||||
live_session::{LiveSession, LiveSessionError},
|
||||
};
|
||||
|
||||
const DEFAULT_PORT: u16 = 34872;
|
||||
@@ -23,11 +24,19 @@ pub struct ServeOptions {
|
||||
#[derive(Debug, Fail)]
|
||||
pub enum ServeError {
|
||||
#[fail(display = "Project load error: {}", _0)]
|
||||
ProjectLoadError(#[fail(cause)] ProjectLoadFuzzyError),
|
||||
ProjectLoadError(#[fail(cause)] ProjectLoadError),
|
||||
|
||||
#[fail(display = "{}", _0)]
|
||||
FsError(#[fail(cause)] FsError),
|
||||
|
||||
#[fail(display = "{}", _0)]
|
||||
LiveSessionError(#[fail(cause)] LiveSessionError),
|
||||
}
|
||||
|
||||
impl_from!(ServeError {
|
||||
ProjectLoadFuzzyError => ProjectLoadError,
|
||||
ProjectLoadError => ProjectLoadError,
|
||||
FsError => FsError,
|
||||
LiveSessionError => LiveSessionError,
|
||||
});
|
||||
|
||||
pub fn serve(options: &ServeOptions) -> Result<(), ServeError> {
|
||||
@@ -38,8 +47,8 @@ pub fn serve(options: &ServeOptions) -> Result<(), ServeError> {
|
||||
info!("Found project at {}", project.file_location.display());
|
||||
info!("Using project {:#?}", project);
|
||||
|
||||
let live_session = Arc::new(LiveSession::new(Arc::clone(&project)).unwrap());
|
||||
let server = Server::new(Arc::clone(&live_session));
|
||||
let live_session = Arc::new(LiveSession::new(Arc::clone(&project))?);
|
||||
let server = LiveServer::new(live_session);
|
||||
|
||||
let port = options.port
|
||||
.or(project.serve_port)
|
||||
@@ -47,7 +56,7 @@ pub fn serve(options: &ServeOptions) -> Result<(), ServeError> {
|
||||
|
||||
println!("Rojo server listening on port {}", port);
|
||||
|
||||
server.listen(port);
|
||||
server.start(port);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -9,9 +9,10 @@ use failure::Fail;
|
||||
use reqwest::header::{ACCEPT, USER_AGENT, CONTENT_TYPE, COOKIE};
|
||||
|
||||
use crate::{
|
||||
imfs::{Imfs, FsError},
|
||||
project::{Project, ProjectLoadError},
|
||||
rbx_session::construct_oneoff_tree,
|
||||
project::{Project, ProjectLoadFuzzyError},
|
||||
imfs::Imfs,
|
||||
rbx_snapshot::SnapshotError,
|
||||
};
|
||||
|
||||
#[derive(Debug, Fail)]
|
||||
@@ -23,7 +24,7 @@ pub enum UploadError {
|
||||
InvalidKind(String),
|
||||
|
||||
#[fail(display = "Project load error: {}", _0)]
|
||||
ProjectLoadError(#[fail(cause)] ProjectLoadFuzzyError),
|
||||
ProjectLoadError(#[fail(cause)] ProjectLoadError),
|
||||
|
||||
#[fail(display = "IO error: {}", _0)]
|
||||
IoError(#[fail(cause)] io::Error),
|
||||
@@ -33,13 +34,21 @@ pub enum UploadError {
|
||||
|
||||
#[fail(display = "XML model file error")]
|
||||
XmlModelEncodeError(rbx_xml::EncodeError),
|
||||
|
||||
#[fail(display = "{}", _0)]
|
||||
FsError(#[fail(cause)] FsError),
|
||||
|
||||
#[fail(display = "{}", _0)]
|
||||
SnapshotError(#[fail(cause)] SnapshotError),
|
||||
}
|
||||
|
||||
impl_from!(UploadError {
|
||||
ProjectLoadFuzzyError => ProjectLoadError,
|
||||
ProjectLoadError => ProjectLoadError,
|
||||
io::Error => IoError,
|
||||
reqwest::Error => HttpError,
|
||||
rbx_xml::EncodeError => XmlModelEncodeError,
|
||||
FsError => FsError,
|
||||
SnapshotError => SnapshotError,
|
||||
});
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -62,7 +71,7 @@ pub fn upload(options: &UploadOptions) -> Result<(), UploadError> {
|
||||
|
||||
let mut imfs = Imfs::new();
|
||||
imfs.add_roots_from_project(&project)?;
|
||||
let tree = construct_oneoff_tree(&project, &imfs);
|
||||
let tree = construct_oneoff_tree(&project, &imfs)?;
|
||||
|
||||
let root_id = tree.get_root_id();
|
||||
let mut contents = Vec::new();
|
||||
@@ -70,10 +79,10 @@ pub fn upload(options: &UploadOptions) -> Result<(), UploadError> {
|
||||
match options.kind {
|
||||
Some("place") | None => {
|
||||
let top_level_ids = tree.get_instance(root_id).unwrap().get_children_ids();
|
||||
rbx_xml::encode(&tree, top_level_ids, &mut contents)?;
|
||||
rbx_xml::to_writer_default(&mut contents, &tree, top_level_ids)?;
|
||||
},
|
||||
Some("model") => {
|
||||
rbx_xml::encode(&tree, &[root_id], &mut contents)?;
|
||||
rbx_xml::to_writer_default(&mut contents, &tree, &[root_id])?;
|
||||
},
|
||||
Some(invalid) => return Err(UploadError::InvalidKind(invalid.to_owned())),
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
use std::{
|
||||
sync::{mpsc, Arc, Mutex},
|
||||
time::Duration,
|
||||
path::Path,
|
||||
ops::Deref,
|
||||
thread,
|
||||
};
|
||||
|
||||
use log::info;
|
||||
use log::{warn, trace};
|
||||
use notify::{
|
||||
self,
|
||||
DebouncedEvent,
|
||||
@@ -20,97 +22,122 @@ use crate::{
|
||||
|
||||
const WATCH_TIMEOUT: Duration = Duration::from_millis(100);
|
||||
|
||||
fn handle_event(imfs: &Mutex<Imfs>, rbx_session: &Mutex<RbxSession>, event: DebouncedEvent) {
|
||||
/// Watches for changes on the filesystem and links together the in-memory
|
||||
/// filesystem and in-memory Roblox tree.
|
||||
pub struct FsWatcher {
|
||||
watcher: RecommendedWatcher,
|
||||
}
|
||||
|
||||
impl FsWatcher {
|
||||
/// Start a new FS watcher, watching all of the roots currently attached to
|
||||
/// the given Imfs.
|
||||
///
|
||||
/// `rbx_session` is optional to make testing easier. If it isn't `None`,
|
||||
/// events will be passed to it after they're given to the Imfs.
|
||||
pub fn start(imfs: Arc<Mutex<Imfs>>, rbx_session: Option<Arc<Mutex<RbxSession>>>) -> FsWatcher {
|
||||
let (watch_tx, watch_rx) = mpsc::channel();
|
||||
|
||||
let mut watcher = notify::watcher(watch_tx, WATCH_TIMEOUT)
|
||||
.expect("Could not create filesystem watcher");
|
||||
|
||||
{
|
||||
let imfs = imfs.lock().unwrap();
|
||||
|
||||
for root_path in imfs.get_roots() {
|
||||
trace!("Watching path {}", root_path.display());
|
||||
watcher.watch(root_path, RecursiveMode::Recursive)
|
||||
.expect("Could not watch directory");
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let imfs = Arc::clone(&imfs);
|
||||
let rbx_session = rbx_session.as_ref().map(Arc::clone);
|
||||
|
||||
thread::spawn(move || {
|
||||
trace!("Watcher thread started");
|
||||
while let Ok(event) = watch_rx.recv() {
|
||||
// handle_fs_event expects an Option<&Mutex<T>>, but we have
|
||||
// an Option<Arc<Mutex<T>>>, so we coerce with Deref.
|
||||
let session_ref = rbx_session.as_ref().map(Deref::deref);
|
||||
|
||||
handle_fs_event(&imfs, session_ref, event);
|
||||
}
|
||||
trace!("Watcher thread stopped");
|
||||
});
|
||||
}
|
||||
|
||||
FsWatcher {
|
||||
watcher,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn stop_watching_path(&mut self, path: &Path) {
|
||||
match self.watcher.unwatch(path) {
|
||||
Ok(_) => {},
|
||||
Err(e) => {
|
||||
warn!("Could not unwatch path {}: {}", path.display(), e);
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_fs_event(imfs: &Mutex<Imfs>, rbx_session: Option<&Mutex<RbxSession>>, event: DebouncedEvent) {
|
||||
match event {
|
||||
DebouncedEvent::Create(path) => {
|
||||
trace!("Path created: {}", path.display());
|
||||
|
||||
{
|
||||
let mut imfs = imfs.lock().unwrap();
|
||||
imfs.path_created(&path).unwrap();
|
||||
}
|
||||
|
||||
{
|
||||
if let Some(rbx_session) = rbx_session {
|
||||
let mut rbx_session = rbx_session.lock().unwrap();
|
||||
rbx_session.path_created(&path);
|
||||
}
|
||||
},
|
||||
DebouncedEvent::Write(path) => {
|
||||
trace!("Path created: {}", path.display());
|
||||
|
||||
{
|
||||
let mut imfs = imfs.lock().unwrap();
|
||||
imfs.path_updated(&path).unwrap();
|
||||
}
|
||||
|
||||
{
|
||||
if let Some(rbx_session) = rbx_session {
|
||||
let mut rbx_session = rbx_session.lock().unwrap();
|
||||
rbx_session.path_updated(&path);
|
||||
}
|
||||
},
|
||||
DebouncedEvent::Remove(path) => {
|
||||
trace!("Path removed: {}", path.display());
|
||||
|
||||
{
|
||||
let mut imfs = imfs.lock().unwrap();
|
||||
imfs.path_removed(&path).unwrap();
|
||||
}
|
||||
|
||||
{
|
||||
if let Some(rbx_session) = rbx_session {
|
||||
let mut rbx_session = rbx_session.lock().unwrap();
|
||||
rbx_session.path_removed(&path);
|
||||
}
|
||||
},
|
||||
DebouncedEvent::Rename(from_path, to_path) => {
|
||||
trace!("Path renamed: {} to {}", from_path.display(), to_path.display());
|
||||
|
||||
{
|
||||
let mut imfs = imfs.lock().unwrap();
|
||||
imfs.path_moved(&from_path, &to_path).unwrap();
|
||||
}
|
||||
|
||||
{
|
||||
if let Some(rbx_session) = rbx_session {
|
||||
let mut rbx_session = rbx_session.lock().unwrap();
|
||||
rbx_session.path_renamed(&from_path, &to_path);
|
||||
}
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
}
|
||||
|
||||
/// Watches for changes on the filesystem and links together the in-memory
|
||||
/// filesystem and in-memory Roblox tree.
|
||||
pub struct FsWatcher {
|
||||
#[allow(unused)]
|
||||
watchers: Vec<RecommendedWatcher>,
|
||||
}
|
||||
|
||||
impl FsWatcher {
|
||||
pub fn start(imfs: Arc<Mutex<Imfs>>, rbx_session: Arc<Mutex<RbxSession>>) -> FsWatcher {
|
||||
let mut watchers = Vec::new();
|
||||
|
||||
{
|
||||
let imfs_temp = imfs.lock().unwrap();
|
||||
|
||||
for root_path in imfs_temp.get_roots() {
|
||||
let (watch_tx, watch_rx) = mpsc::channel();
|
||||
|
||||
let mut watcher = notify::watcher(watch_tx, WATCH_TIMEOUT)
|
||||
.expect("Could not create `notify` watcher");
|
||||
|
||||
watcher.watch(root_path, RecursiveMode::Recursive)
|
||||
.expect("Could not watch directory");
|
||||
|
||||
watchers.push(watcher);
|
||||
|
||||
let imfs = Arc::clone(&imfs);
|
||||
let rbx_session = Arc::clone(&rbx_session);
|
||||
let root_path = root_path.clone();
|
||||
|
||||
thread::spawn(move || {
|
||||
info!("Watcher thread ({}) started", root_path.display());
|
||||
while let Ok(event) = watch_rx.recv() {
|
||||
handle_event(&imfs, &rbx_session, event);
|
||||
}
|
||||
info!("Watcher thread ({}) stopped", root_path.display());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
FsWatcher {
|
||||
watchers,
|
||||
}
|
||||
other => {
|
||||
trace!("Unhandled FS event: {:?}", other);
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,59 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
path::{Path, PathBuf},
|
||||
cmp::Ordering,
|
||||
collections::{HashMap, HashSet, BTreeSet},
|
||||
fmt,
|
||||
fs,
|
||||
io,
|
||||
path::{self, Path, PathBuf},
|
||||
};
|
||||
|
||||
use serde_derive::{Serialize, Deserialize};
|
||||
use failure::Fail;
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
use crate::project::{Project, ProjectNode};
|
||||
|
||||
fn add_sync_points(imfs: &mut Imfs, project_node: &ProjectNode) -> io::Result<()> {
|
||||
match project_node {
|
||||
ProjectNode::Instance(node) => {
|
||||
for child in node.children.values() {
|
||||
add_sync_points(imfs, child)?;
|
||||
}
|
||||
},
|
||||
ProjectNode::SyncPoint(node) => {
|
||||
imfs.add_root(&node.path)?;
|
||||
},
|
||||
/// A wrapper around io::Error that also attaches the path associated with the
|
||||
/// error.
|
||||
#[derive(Debug, Fail)]
|
||||
pub struct FsError {
|
||||
#[fail(cause)]
|
||||
inner: io::Error,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
impl FsError {
|
||||
fn new<P: Into<PathBuf>>(inner: io::Error, path: P) -> FsError {
|
||||
FsError {
|
||||
inner,
|
||||
path: path.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for FsError {
|
||||
fn fmt(&self, output: &mut fmt::Formatter) -> fmt::Result {
|
||||
write!(output, "{}: {}", self.path.display(), self.inner)
|
||||
}
|
||||
}
|
||||
|
||||
fn add_sync_points(imfs: &mut Imfs, node: &ProjectNode) -> Result<(), FsError> {
|
||||
if let Some(path) = &node.path {
|
||||
imfs.add_root(path)?;
|
||||
}
|
||||
|
||||
for child in node.children.values() {
|
||||
add_sync_points(imfs, child)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The in-memory filesystem keeps a mirror of all files being watcher by Rojo
|
||||
/// The in-memory filesystem keeps a mirror of all files being watched by Rojo
|
||||
/// in order to deduplicate file changes in the case of bidirectional syncing
|
||||
/// from Roblox Studio.
|
||||
///
|
||||
/// It also enables Rojo to quickly generate React-like snapshots to make
|
||||
/// reasoning about instances and how they relate to files easier.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Imfs {
|
||||
items: HashMap<PathBuf, ImfsItem>,
|
||||
@@ -41,7 +68,7 @@ impl Imfs {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_roots_from_project(&mut self, project: &Project) -> io::Result<()> {
|
||||
pub fn add_roots_from_project(&mut self, project: &Project) -> Result<(), FsError> {
|
||||
add_sync_points(self, &project.tree)
|
||||
}
|
||||
|
||||
@@ -60,30 +87,44 @@ impl Imfs {
|
||||
self.items.get(path)
|
||||
}
|
||||
|
||||
pub fn add_root(&mut self, path: &Path) -> io::Result<()> {
|
||||
pub fn add_root(&mut self, path: &Path) -> Result<(), FsError> {
|
||||
debug_assert!(path.is_absolute());
|
||||
debug_assert!(!self.is_within_roots(path));
|
||||
|
||||
self.roots.insert(path.to_path_buf());
|
||||
if !self.is_within_roots(path) {
|
||||
self.roots.insert(path.to_path_buf());
|
||||
self.descend_and_read_from_disk(path)?;
|
||||
}
|
||||
|
||||
self.read_from_disk(path)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn path_created(&mut self, path: &Path) -> io::Result<()> {
|
||||
pub fn remove_root(&mut self, path: &Path) {
|
||||
debug_assert!(path.is_absolute());
|
||||
|
||||
if self.roots.get(path).is_some() {
|
||||
self.remove_item(path);
|
||||
|
||||
if let Some(parent_path) = path.parent() {
|
||||
self.unlink_child(parent_path, path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn path_created(&mut self, path: &Path) -> Result<(), FsError> {
|
||||
debug_assert!(path.is_absolute());
|
||||
debug_assert!(self.is_within_roots(path));
|
||||
|
||||
self.read_from_disk(path)
|
||||
self.descend_and_read_from_disk(path)
|
||||
}
|
||||
|
||||
pub fn path_updated(&mut self, path: &Path) -> io::Result<()> {
|
||||
pub fn path_updated(&mut self, path: &Path) -> Result<(), FsError> {
|
||||
debug_assert!(path.is_absolute());
|
||||
debug_assert!(self.is_within_roots(path));
|
||||
|
||||
self.read_from_disk(path)
|
||||
self.descend_and_read_from_disk(path)
|
||||
}
|
||||
|
||||
pub fn path_removed(&mut self, path: &Path) -> io::Result<()> {
|
||||
pub fn path_removed(&mut self, path: &Path) -> Result<(), FsError> {
|
||||
debug_assert!(path.is_absolute());
|
||||
debug_assert!(self.is_within_roots(path));
|
||||
|
||||
@@ -96,12 +137,7 @@ impl Imfs {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn path_moved(&mut self, from_path: &Path, to_path: &Path) -> io::Result<()> {
|
||||
debug_assert!(from_path.is_absolute());
|
||||
debug_assert!(self.is_within_roots(from_path));
|
||||
debug_assert!(to_path.is_absolute());
|
||||
debug_assert!(self.is_within_roots(to_path));
|
||||
|
||||
pub fn path_moved(&mut self, from_path: &Path, to_path: &Path) -> Result<(), FsError> {
|
||||
self.path_removed(from_path)?;
|
||||
self.path_created(to_path)?;
|
||||
Ok(())
|
||||
@@ -132,9 +168,7 @@ impl Imfs {
|
||||
Some(ImfsItem::Directory(directory)) => {
|
||||
directory.children.remove(child);
|
||||
},
|
||||
_ => {
|
||||
panic!("Tried to unlink child of path that wasn't a directory!");
|
||||
},
|
||||
_ => {},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,11 +187,44 @@ impl Imfs {
|
||||
}
|
||||
}
|
||||
|
||||
fn read_from_disk(&mut self, path: &Path) -> io::Result<()> {
|
||||
let metadata = fs::metadata(path)?;
|
||||
fn descend_and_read_from_disk(&mut self, path: &Path) -> Result<(), FsError> {
|
||||
let root_path = self.get_root_path(path)
|
||||
.expect("Tried to descent and read for path that wasn't within roots!");
|
||||
|
||||
// If this path is a root, we should read the entire thing.
|
||||
if root_path == path {
|
||||
self.read_from_disk(path)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let relative_path = path.strip_prefix(root_path).unwrap();
|
||||
let mut current_path = root_path.to_path_buf();
|
||||
|
||||
for component in relative_path.components() {
|
||||
match component {
|
||||
path::Component::Normal(name) => {
|
||||
let next_path = current_path.join(name);
|
||||
|
||||
if self.items.contains_key(&next_path) {
|
||||
current_path = next_path;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
},
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
self.read_from_disk(¤t_path)
|
||||
}
|
||||
|
||||
fn read_from_disk(&mut self, path: &Path) -> Result<(), FsError> {
|
||||
let metadata = fs::metadata(path)
|
||||
.map_err(|e| FsError::new(e, path))?;
|
||||
|
||||
if metadata.is_file() {
|
||||
let contents = fs::read(path)?;
|
||||
let contents = fs::read(path)
|
||||
.map_err(|e| FsError::new(e, path))?;
|
||||
let item = ImfsItem::File(ImfsFile {
|
||||
path: path.to_path_buf(),
|
||||
contents,
|
||||
@@ -173,13 +240,18 @@ impl Imfs {
|
||||
} else if metadata.is_dir() {
|
||||
let item = ImfsItem::Directory(ImfsDirectory {
|
||||
path: path.to_path_buf(),
|
||||
children: HashSet::new(),
|
||||
children: BTreeSet::new(),
|
||||
});
|
||||
|
||||
self.items.insert(path.to_path_buf(), item);
|
||||
|
||||
for entry in fs::read_dir(path)? {
|
||||
let entry = entry?;
|
||||
let dir_children = fs::read_dir(path)
|
||||
.map_err(|e| FsError::new(e, path))?;
|
||||
|
||||
for entry in dir_children {
|
||||
let entry = entry
|
||||
.map_err(|e| FsError::new(e, path))?;
|
||||
|
||||
let child_path = entry.path();
|
||||
|
||||
self.read_from_disk(&child_path)?;
|
||||
@@ -195,6 +267,16 @@ impl Imfs {
|
||||
}
|
||||
}
|
||||
|
||||
fn get_root_path<'a>(&'a self, path: &Path) -> Option<&'a Path> {
|
||||
for root_path in &self.roots {
|
||||
if path.starts_with(root_path) {
|
||||
return Some(root_path)
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn is_within_roots(&self, path: &Path) -> bool {
|
||||
for root_path in &self.roots {
|
||||
if path.starts_with(root_path) {
|
||||
@@ -206,19 +288,43 @@ impl Imfs {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ImfsFile {
|
||||
pub path: PathBuf,
|
||||
pub contents: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ImfsDirectory {
|
||||
pub path: PathBuf,
|
||||
pub children: HashSet<PathBuf>,
|
||||
impl PartialOrd for ImfsFile {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
impl Ord for ImfsFile {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
self.path.cmp(&other.path)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ImfsDirectory {
|
||||
pub path: PathBuf,
|
||||
pub children: BTreeSet<PathBuf>,
|
||||
}
|
||||
|
||||
impl PartialOrd for ImfsDirectory {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for ImfsDirectory {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
self.path.cmp(&other.path)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub enum ImfsItem {
|
||||
File(ImfsFile),
|
||||
Directory(ImfsDirectory),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
#![recursion_limit="128"]
|
||||
|
||||
// Macros
|
||||
#[macro_use]
|
||||
pub mod impl_from;
|
||||
@@ -6,14 +8,14 @@ pub mod impl_from;
|
||||
pub mod commands;
|
||||
pub mod fs_watcher;
|
||||
pub mod imfs;
|
||||
pub mod live_session;
|
||||
pub mod message_queue;
|
||||
pub mod path_map;
|
||||
pub mod path_serializer;
|
||||
pub mod project;
|
||||
pub mod rbx_session;
|
||||
pub mod rbx_snapshot;
|
||||
pub mod live_session;
|
||||
pub mod session_id;
|
||||
pub mod snapshot_reconciler;
|
||||
pub mod visualize;
|
||||
pub mod web;
|
||||
pub mod web_util;
|
||||
pub mod web;
|
||||
@@ -1,22 +1,40 @@
|
||||
use std::{
|
||||
collections::HashSet,
|
||||
mem,
|
||||
sync::{Arc, Mutex},
|
||||
io,
|
||||
};
|
||||
|
||||
use failure::Fail;
|
||||
|
||||
use crate::{
|
||||
fs_watcher::FsWatcher,
|
||||
imfs::{Imfs, FsError},
|
||||
message_queue::MessageQueue,
|
||||
project::Project,
|
||||
imfs::Imfs,
|
||||
session_id::SessionId,
|
||||
rbx_session::RbxSession,
|
||||
rbx_snapshot::SnapshotError,
|
||||
session_id::SessionId,
|
||||
snapshot_reconciler::InstanceChanges,
|
||||
fs_watcher::FsWatcher,
|
||||
};
|
||||
|
||||
#[derive(Debug, Fail)]
|
||||
pub enum LiveSessionError {
|
||||
#[fail(display = "{}", _0)]
|
||||
Fs(#[fail(cause)] FsError),
|
||||
|
||||
#[fail(display = "{}", _0)]
|
||||
Snapshot(#[fail(cause)] SnapshotError),
|
||||
}
|
||||
|
||||
impl_from!(LiveSessionError {
|
||||
FsError => Fs,
|
||||
SnapshotError => Snapshot,
|
||||
});
|
||||
|
||||
/// Contains all of the state for a Rojo live-sync session.
|
||||
pub struct LiveSession {
|
||||
pub project: Arc<Project>,
|
||||
pub session_id: SessionId,
|
||||
project: Arc<Project>,
|
||||
session_id: SessionId,
|
||||
pub message_queue: Arc<MessageQueue<InstanceChanges>>,
|
||||
pub rbx_session: Arc<Mutex<RbxSession>>,
|
||||
pub imfs: Arc<Mutex<Imfs>>,
|
||||
@@ -24,7 +42,7 @@ pub struct LiveSession {
|
||||
}
|
||||
|
||||
impl LiveSession {
|
||||
pub fn new(project: Arc<Project>) -> io::Result<LiveSession> {
|
||||
pub fn new(project: Arc<Project>) -> Result<LiveSession, LiveSessionError> {
|
||||
let imfs = {
|
||||
let mut imfs = Imfs::new();
|
||||
imfs.add_roots_from_project(&project)?;
|
||||
@@ -37,18 +55,18 @@ impl LiveSession {
|
||||
Arc::clone(&project),
|
||||
Arc::clone(&imfs),
|
||||
Arc::clone(&message_queue),
|
||||
)));
|
||||
)?));
|
||||
|
||||
let fs_watcher = FsWatcher::start(
|
||||
Arc::clone(&imfs),
|
||||
Arc::clone(&rbx_session),
|
||||
Some(Arc::clone(&rbx_session)),
|
||||
);
|
||||
|
||||
let session_id = SessionId::new();
|
||||
|
||||
Ok(LiveSession {
|
||||
project,
|
||||
session_id,
|
||||
project,
|
||||
message_queue,
|
||||
rbx_session,
|
||||
imfs,
|
||||
@@ -56,7 +74,26 @@ impl LiveSession {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn get_project(&self) -> &Project {
|
||||
/// Restarts the live session using the given project while preserving the
|
||||
/// internal session ID.
|
||||
pub fn restart_with_new_project(&mut self, project: Arc<Project>) -> Result<(), LiveSessionError> {
|
||||
let mut new_session = LiveSession::new(project)?;
|
||||
new_session.session_id = self.session_id;
|
||||
|
||||
mem::replace(self, new_session);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn root_project(&self) -> &Project {
|
||||
&self.project
|
||||
}
|
||||
|
||||
pub fn session_id(&self) -> SessionId {
|
||||
self.session_id
|
||||
}
|
||||
|
||||
pub fn serve_place_ids(&self) -> &Option<HashSet<u64>> {
|
||||
&self.project.serve_place_ids
|
||||
}
|
||||
}
|
||||
@@ -1,67 +1,83 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
mem,
|
||||
sync::{
|
||||
mpsc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
RwLock,
|
||||
Mutex,
|
||||
},
|
||||
};
|
||||
|
||||
/// A unique identifier, not guaranteed to be generated in any order.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct ListenerId(usize);
|
||||
use futures::sync::oneshot;
|
||||
|
||||
/// Generate a new ID, which has no defined ordering.
|
||||
pub fn get_listener_id() -> ListenerId {
|
||||
static LAST_ID: AtomicUsize = AtomicUsize::new(0);
|
||||
struct Listener<T> {
|
||||
sender: oneshot::Sender<(u32, Vec<T>)>,
|
||||
cursor: u32,
|
||||
}
|
||||
|
||||
ListenerId(LAST_ID.fetch_add(1, Ordering::SeqCst))
|
||||
fn fire_listener_if_ready<T: Clone>(messages: &[T], listener: Listener<T>) -> Result<(), Listener<T>> {
|
||||
let current_cursor = messages.len() as u32;
|
||||
|
||||
if listener.cursor < current_cursor {
|
||||
let new_messages = messages[(listener.cursor as usize)..].to_vec();
|
||||
let _ = listener.sender.send((current_cursor, new_messages));
|
||||
Ok(())
|
||||
} else {
|
||||
Err(listener)
|
||||
}
|
||||
}
|
||||
|
||||
/// A message queue with persistent history that can be subscribed to.
|
||||
///
|
||||
/// Definitely non-optimal, but a simple design that works well for the
|
||||
/// synchronous web server Rojo uses, Rouille.
|
||||
/// Definitely non-optimal. This would ideally be a lockless mpmc queue.
|
||||
#[derive(Default)]
|
||||
pub struct MessageQueue<T> {
|
||||
messages: RwLock<Vec<T>>,
|
||||
message_listeners: Mutex<HashMap<ListenerId, mpsc::Sender<()>>>,
|
||||
message_listeners: Mutex<Vec<Listener<T>>>,
|
||||
}
|
||||
|
||||
impl<T: Clone> MessageQueue<T> {
|
||||
pub fn new() -> MessageQueue<T> {
|
||||
MessageQueue {
|
||||
messages: RwLock::new(Vec::new()),
|
||||
message_listeners: Mutex::new(HashMap::new()),
|
||||
message_listeners: Mutex::new(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_messages(&self, new_messages: &[T]) {
|
||||
let message_listeners = self.message_listeners.lock().unwrap();
|
||||
let mut message_listeners = self.message_listeners.lock().unwrap();
|
||||
let mut messages = self.messages.write().unwrap();
|
||||
messages.extend_from_slice(new_messages);
|
||||
|
||||
{
|
||||
let mut messages = self.messages.write().unwrap();
|
||||
messages.extend_from_slice(new_messages);
|
||||
let mut remaining_listeners = Vec::new();
|
||||
|
||||
for listener in message_listeners.drain(..) {
|
||||
match fire_listener_if_ready(&messages, listener) {
|
||||
Ok(_) => {}
|
||||
Err(listener) => remaining_listeners.push(listener)
|
||||
}
|
||||
}
|
||||
|
||||
for listener in message_listeners.values() {
|
||||
listener.send(()).unwrap();
|
||||
}
|
||||
// Without this annotation, Rust gets confused since the first argument
|
||||
// is a MutexGuard, but the second is a Vec.
|
||||
mem::replace::<Vec<_>>(&mut message_listeners, remaining_listeners);
|
||||
}
|
||||
|
||||
pub fn subscribe(&self, sender: mpsc::Sender<()>) -> ListenerId {
|
||||
let id = get_listener_id();
|
||||
pub fn subscribe(&self, cursor: u32, sender: oneshot::Sender<(u32, Vec<T>)>) {
|
||||
let listener = {
|
||||
let listener = Listener {
|
||||
sender,
|
||||
cursor,
|
||||
};
|
||||
|
||||
let messages = self.messages.read().unwrap();
|
||||
|
||||
match fire_listener_if_ready(&messages, listener) {
|
||||
Ok(_) => return,
|
||||
Err(listener) => listener
|
||||
}
|
||||
};
|
||||
|
||||
let mut message_listeners = self.message_listeners.lock().unwrap();
|
||||
message_listeners.insert(id, sender);
|
||||
|
||||
id
|
||||
}
|
||||
|
||||
pub fn unsubscribe(&self, id: ListenerId) {
|
||||
let mut message_listeners = self.message_listeners.lock().unwrap();
|
||||
message_listeners.remove(&id);
|
||||
message_listeners.push(listener);
|
||||
}
|
||||
|
||||
pub fn get_message_cursor(&self) -> u32 {
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
use std::{
|
||||
collections::hash_map,
|
||||
path::{self, Path, PathBuf},
|
||||
collections::{HashMap, HashSet},
|
||||
};
|
||||
|
||||
use serde_derive::Serialize;
|
||||
use serde::Serialize;
|
||||
use log::warn;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -21,6 +20,12 @@ pub struct PathMap<T> {
|
||||
nodes: HashMap<PathBuf, PathMapNode<T>>,
|
||||
}
|
||||
|
||||
impl<T> Default for PathMap<T> {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> PathMap<T> {
|
||||
pub fn new() -> PathMap<T> {
|
||||
PathMap {
|
||||
@@ -36,12 +41,6 @@ impl<T> PathMap<T> {
|
||||
self.nodes.get_mut(path).map(|v| &mut v.value)
|
||||
}
|
||||
|
||||
pub fn entry<'a>(&'a mut self, path: PathBuf) -> Entry<'a, T> {
|
||||
Entry {
|
||||
internal: self.nodes.entry(path),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn insert(&mut self, path: PathBuf, value: T) {
|
||||
if let Some(parent_path) = path.parent() {
|
||||
if let Some(parent) = self.nodes.get_mut(parent_path) {
|
||||
@@ -116,28 +115,4 @@ impl<T> PathMap<T> {
|
||||
|
||||
current_path
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Entry<'a, T> {
|
||||
internal: hash_map::Entry<'a, PathBuf, PathMapNode<T>>,
|
||||
}
|
||||
|
||||
impl<'a, T> Entry<'a, T> {
|
||||
pub fn or_insert(self, value: T) -> &'a mut T {
|
||||
&mut self.internal.or_insert(PathMapNode {
|
||||
value,
|
||||
children: HashSet::new(),
|
||||
}).value
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a, T> Entry<'a, T>
|
||||
where T: Default
|
||||
{
|
||||
pub fn or_default(self) -> &'a mut T {
|
||||
&mut self.internal.or_insert(PathMapNode {
|
||||
value: Default::default(),
|
||||
children: HashSet::new(),
|
||||
}).value
|
||||
}
|
||||
}
|
||||
69
server/src/path_serializer.rs
Normal file
@@ -0,0 +1,69 @@
|
||||
//! path_serializer is used in cases where we need to serialize relative Path
|
||||
//! and PathBuf objects in a way that's cross-platform.
|
||||
//!
|
||||
//! This is used for the snapshot testing system to make sure that snapshots
|
||||
//! that reference local paths that are generated on Windows don't fail when run
|
||||
//! in systems that use a different directory separator.
|
||||
//!
|
||||
//! To use, annotate your PathBuf or Option<PathBuf> field with the correct
|
||||
//! serializer function:
|
||||
//!
|
||||
//! ```
|
||||
//! # use std::path::PathBuf;
|
||||
//! # use serde::{Serialize, Deserialize};
|
||||
//!
|
||||
//! #[derive(Serialize, Deserialize)]
|
||||
//! struct Mine {
|
||||
//! name: String,
|
||||
//!
|
||||
//! // Use 'crate' instead of librojo if writing code inside Rojo
|
||||
//! #[serde(serialize_with = "librojo::path_serializer::serialize")]
|
||||
//! source_path: PathBuf,
|
||||
//!
|
||||
//! #[serde(serialize_with = "librojo::path_serializer::serialize_option")]
|
||||
//! maybe_path: Option<PathBuf>,
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! **The methods in this module can only handle relative paths, since absolute
|
||||
//! paths are never portable.**
|
||||
|
||||
use std::path::{Component, Path};
|
||||
|
||||
use serde::Serializer;
|
||||
|
||||
pub fn serialize_option<S, T>(maybe_path: &Option<T>, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where S: Serializer,
|
||||
T: AsRef<Path>,
|
||||
{
|
||||
match maybe_path {
|
||||
Some(path) => serialize(path, serializer),
|
||||
None => serializer.serialize_none()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn serialize<S, T>(path: T, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where S: Serializer,
|
||||
T: AsRef<Path>,
|
||||
{
|
||||
let path = path.as_ref();
|
||||
|
||||
assert!(path.is_relative(), "path_serializer can only handle relative paths");
|
||||
|
||||
let mut output = String::new();
|
||||
|
||||
for component in path.components() {
|
||||
if !output.is_empty() {
|
||||
output.push('/');
|
||||
}
|
||||
|
||||
match component {
|
||||
Component::CurDir => output.push('.'),
|
||||
Component::ParentDir => output.push_str(".."),
|
||||
Component::Normal(piece) => output.push_str(piece.to_str().unwrap()),
|
||||
_ => panic!("path_serializer cannot handle absolute path components"),
|
||||
}
|
||||
}
|
||||
|
||||
serializer.serialize_str(&output)
|
||||
}
|
||||
@@ -1,37 +1,34 @@
|
||||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
collections::{HashMap, HashSet, BTreeMap},
|
||||
fmt,
|
||||
fs::{self, File},
|
||||
io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use log::warn;
|
||||
use failure::Fail;
|
||||
use maplit::hashmap;
|
||||
use rbx_tree::RbxValue;
|
||||
use serde_derive::{Serialize, Deserialize};
|
||||
use rbx_dom_weak::{UnresolvedRbxValue, RbxValue};
|
||||
use serde::{Serialize, Serializer, Deserialize};
|
||||
|
||||
pub static PROJECT_FILENAME: &'static str = "roblox-project.json";
|
||||
static DEFAULT_PLACE: &'static str = include_str!("../assets/place.project.json");
|
||||
|
||||
// Methods used for Serde's default value system, which doesn't support using
|
||||
// value literals directly, only functions that return values.
|
||||
const fn yeah() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
const fn is_true(value: &bool) -> bool {
|
||||
*value
|
||||
}
|
||||
pub static PROJECT_FILENAME: &'static str = "default.project.json";
|
||||
pub static COMPAT_PROJECT_FILENAME: &'static str = "roblox-project.json";
|
||||
|
||||
/// SourceProject is the format that users author projects on-disk. Since we
|
||||
/// want to do things like transforming paths to be absolute before handing them
|
||||
/// off to the rest of Rojo, we use this intermediate struct.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields, rename_all = "camelCase")]
|
||||
struct SourceProject {
|
||||
name: String,
|
||||
tree: SourceProjectNode,
|
||||
|
||||
#[cfg_attr(not(feature = "plugins-enabled"), serde(skip_deserializing))]
|
||||
#[serde(default = "Vec::new", skip_serializing_if = "Vec::is_empty")]
|
||||
plugins: Vec<SourcePlugin>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
serve_port: Option<u16>,
|
||||
|
||||
@@ -41,12 +38,17 @@ struct SourceProject {
|
||||
|
||||
impl SourceProject {
|
||||
/// Consumes the SourceProject and yields a Project, ready for prime-time.
|
||||
pub fn into_project(self, project_file_location: &Path) -> Project {
|
||||
pub fn into_project(mut self, project_file_location: &Path) -> Project {
|
||||
let tree = self.tree.into_project_node(project_file_location);
|
||||
let plugins = self.plugins
|
||||
.drain(..)
|
||||
.map(|source_plugin| source_plugin.into_plugin(project_file_location))
|
||||
.collect();
|
||||
|
||||
Project {
|
||||
name: self.name,
|
||||
tree,
|
||||
plugins,
|
||||
serve_port: self.serve_port,
|
||||
serve_place_ids: self.serve_place_ids,
|
||||
file_location: PathBuf::from(project_file_location),
|
||||
@@ -54,95 +56,178 @@ impl SourceProject {
|
||||
}
|
||||
}
|
||||
|
||||
/// An alternative serializer for `UnresolvedRbxValue` that uses the minimum
|
||||
/// representation of the value.
|
||||
///
|
||||
/// For example, the default Serialize impl might give you:
|
||||
///
|
||||
/// ```json
|
||||
/// {
|
||||
/// "Type": "Bool",
|
||||
/// "Value": true
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// But in reality, users are expected to write just:
|
||||
///
|
||||
/// ```json
|
||||
/// true
|
||||
/// ```
|
||||
///
|
||||
/// This holds true for other values that might be ambiguous or just have more
|
||||
/// complicated representations like enums.
|
||||
fn serialize_unresolved_minimal<S>(unresolved: &UnresolvedRbxValue, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where S: Serializer
|
||||
{
|
||||
match unresolved {
|
||||
UnresolvedRbxValue::Ambiguous(_) => unresolved.serialize(serializer),
|
||||
UnresolvedRbxValue::Concrete(concrete) => {
|
||||
match concrete {
|
||||
RbxValue::Bool { value } => value.serialize(serializer),
|
||||
RbxValue::CFrame { value } => value.serialize(serializer),
|
||||
RbxValue::Color3 { value } => value.serialize(serializer),
|
||||
RbxValue::Color3uint8 { value } => value.serialize(serializer),
|
||||
RbxValue::Content { value } => value.serialize(serializer),
|
||||
RbxValue::Float32 { value } => value.serialize(serializer),
|
||||
RbxValue::Int32 { value } => value.serialize(serializer),
|
||||
RbxValue::String { value } => value.serialize(serializer),
|
||||
RbxValue::UDim { value } => value.serialize(serializer),
|
||||
RbxValue::UDim2 { value } => value.serialize(serializer),
|
||||
RbxValue::Vector2 { value } => value.serialize(serializer),
|
||||
RbxValue::Vector2int16 { value } => value.serialize(serializer),
|
||||
RbxValue::Vector3 { value } => value.serialize(serializer),
|
||||
RbxValue::Vector3int16 { value } => value.serialize(serializer),
|
||||
_ => concrete.serialize(serializer),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// A wrapper around serialize_unresolved_minimal that handles the HashMap case.
|
||||
fn serialize_unresolved_map<S>(value: &HashMap<String, UnresolvedRbxValue>, serializer: S) -> Result<S::Ok, S::Error>
|
||||
where S: Serializer
|
||||
{
|
||||
use serde::ser::SerializeMap;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct Minimal<'a>(
|
||||
#[serde(serialize_with = "serialize_unresolved_minimal")]
|
||||
&'a UnresolvedRbxValue
|
||||
);
|
||||
|
||||
let mut map = serializer.serialize_map(Some(value.len()))?;
|
||||
for (k, v) in value {
|
||||
map.serialize_key(k)?;
|
||||
map.serialize_value(&Minimal(v))?;
|
||||
}
|
||||
map.end()
|
||||
}
|
||||
|
||||
/// Similar to SourceProject, the structure of nodes in the project tree is
|
||||
/// slightly different on-disk than how we want to handle them in the rest of
|
||||
/// Rojo.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum SourceProjectNode {
|
||||
Instance {
|
||||
#[serde(rename = "$className")]
|
||||
class_name: String,
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct SourceProjectNode {
|
||||
#[serde(rename = "$className", skip_serializing_if = "Option::is_none")]
|
||||
class_name: Option<String>,
|
||||
|
||||
#[serde(rename = "$properties", default = "HashMap::new", skip_serializing_if = "HashMap::is_empty")]
|
||||
properties: HashMap<String, RbxValue>,
|
||||
#[serde(
|
||||
rename = "$properties",
|
||||
default = "HashMap::new",
|
||||
skip_serializing_if = "HashMap::is_empty",
|
||||
serialize_with = "serialize_unresolved_map",
|
||||
)]
|
||||
properties: HashMap<String, UnresolvedRbxValue>,
|
||||
|
||||
#[serde(rename = "$ignoreUnknownInstances", default = "yeah", skip_serializing_if = "is_true")]
|
||||
ignore_unknown_instances: bool,
|
||||
#[serde(rename = "$ignoreUnknownInstances", skip_serializing_if = "Option::is_none")]
|
||||
ignore_unknown_instances: Option<bool>,
|
||||
|
||||
#[serde(flatten)]
|
||||
children: HashMap<String, SourceProjectNode>,
|
||||
},
|
||||
SyncPoint {
|
||||
#[serde(rename = "$path")]
|
||||
path: String,
|
||||
}
|
||||
#[serde(rename = "$path", skip_serializing_if = "Option::is_none")]
|
||||
path: Option<String>,
|
||||
|
||||
#[serde(flatten)]
|
||||
children: BTreeMap<String, SourceProjectNode>,
|
||||
}
|
||||
|
||||
impl SourceProjectNode {
|
||||
/// Consumes the SourceProjectNode and turns it into a ProjectNode.
|
||||
pub fn into_project_node(self, project_file_location: &Path) -> ProjectNode {
|
||||
match self {
|
||||
SourceProjectNode::Instance { class_name, mut children, properties, ignore_unknown_instances } => {
|
||||
let mut new_children = HashMap::new();
|
||||
let children = self.children.iter()
|
||||
.map(|(key, value)| (key.clone(), value.clone().into_project_node(project_file_location)))
|
||||
.collect();
|
||||
|
||||
for (node_name, node) in children.drain() {
|
||||
new_children.insert(node_name, node.into_project_node(project_file_location));
|
||||
}
|
||||
// Make sure that paths are absolute, transforming them by adding the
|
||||
// project folder if they're not already absolute.
|
||||
let path = self.path.as_ref().map(|source_path| {
|
||||
if Path::new(source_path).is_absolute() {
|
||||
PathBuf::from(source_path)
|
||||
} else {
|
||||
let project_folder_location = project_file_location.parent().unwrap();
|
||||
project_folder_location.join(source_path)
|
||||
}
|
||||
});
|
||||
|
||||
ProjectNode::Instance(InstanceProjectNode {
|
||||
class_name,
|
||||
children: new_children,
|
||||
properties,
|
||||
metadata: InstanceProjectNodeMetadata {
|
||||
ignore_unknown_instances,
|
||||
},
|
||||
})
|
||||
},
|
||||
SourceProjectNode::SyncPoint { path: source_path } => {
|
||||
let path = if Path::new(&source_path).is_absolute() {
|
||||
PathBuf::from(source_path)
|
||||
} else {
|
||||
let project_folder_location = project_file_location.parent().unwrap();
|
||||
project_folder_location.join(source_path)
|
||||
};
|
||||
|
||||
ProjectNode::SyncPoint(SyncPointProjectNode {
|
||||
path,
|
||||
})
|
||||
},
|
||||
ProjectNode {
|
||||
class_name: self.class_name,
|
||||
properties: self.properties,
|
||||
ignore_unknown_instances: self.ignore_unknown_instances,
|
||||
path,
|
||||
children,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error returned by Project::load_exact
|
||||
#[derive(Debug, Fail)]
|
||||
pub enum ProjectLoadExactError {
|
||||
#[fail(display = "IO error: {}", _0)]
|
||||
IoError(#[fail(cause)] io::Error),
|
||||
|
||||
#[fail(display = "JSON error: {}", _0)]
|
||||
JsonError(#[fail(cause)] serde_json::Error),
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct SourcePlugin {
|
||||
path: String,
|
||||
}
|
||||
|
||||
impl SourcePlugin {
|
||||
pub fn into_plugin(self, project_file_location: &Path) -> Plugin {
|
||||
let path = if Path::new(&self.path).is_absolute() {
|
||||
PathBuf::from(self.path)
|
||||
} else {
|
||||
let project_folder_location = project_file_location.parent().unwrap();
|
||||
project_folder_location.join(self.path)
|
||||
};
|
||||
|
||||
Plugin {
|
||||
path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Error returned by Project::load_fuzzy
|
||||
#[derive(Debug, Fail)]
|
||||
pub enum ProjectLoadFuzzyError {
|
||||
#[fail(display = "Project not found")]
|
||||
pub enum ProjectLoadError {
|
||||
NotFound,
|
||||
|
||||
#[fail(display = "IO error: {}", _0)]
|
||||
IoError(#[fail(cause)] io::Error),
|
||||
Io {
|
||||
#[fail(cause)]
|
||||
inner: io::Error,
|
||||
path: PathBuf,
|
||||
},
|
||||
|
||||
#[fail(display = "JSON error: {}", _0)]
|
||||
JsonError(#[fail(cause)] serde_json::Error),
|
||||
Json {
|
||||
#[fail(cause)]
|
||||
inner: serde_json::Error,
|
||||
path: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<ProjectLoadExactError> for ProjectLoadFuzzyError {
|
||||
fn from(error: ProjectLoadExactError) -> ProjectLoadFuzzyError {
|
||||
match error {
|
||||
ProjectLoadExactError::IoError(inner) => ProjectLoadFuzzyError::IoError(inner),
|
||||
ProjectLoadExactError::JsonError(inner) => ProjectLoadFuzzyError::JsonError(inner),
|
||||
impl fmt::Display for ProjectLoadError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
|
||||
use self::ProjectLoadError::*;
|
||||
|
||||
match self {
|
||||
NotFound => {
|
||||
write!(formatter, "Project file not found")
|
||||
}
|
||||
Io { inner, path } => {
|
||||
write!(formatter, "I/O error: {} in path {}", inner, path.display())
|
||||
}
|
||||
Json { inner, path } => {
|
||||
write!(formatter, "JSON error: {} in path {}", inner, path.display())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -153,6 +238,7 @@ pub enum ProjectInitError {
|
||||
AlreadyExists(PathBuf),
|
||||
IoError(#[fail(cause)] io::Error),
|
||||
SaveError(#[fail(cause)] ProjectSaveError),
|
||||
JsonError(#[fail(cause)] serde_json::Error),
|
||||
}
|
||||
|
||||
impl fmt::Display for ProjectInitError {
|
||||
@@ -161,6 +247,7 @@ impl fmt::Display for ProjectInitError {
|
||||
ProjectInitError::AlreadyExists(path) => write!(output, "Path {} already exists", path.display()),
|
||||
ProjectInitError::IoError(inner) => write!(output, "IO error: {}", inner),
|
||||
ProjectInitError::SaveError(inner) => write!(output, "{}", inner),
|
||||
ProjectInitError::JsonError(inner) => write!(output, "{}", inner),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,79 +262,84 @@ pub enum ProjectSaveError {
|
||||
IoError(#[fail(cause)] io::Error),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstanceProjectNodeMetadata {
|
||||
pub ignore_unknown_instances: bool,
|
||||
}
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct ProjectNode {
|
||||
pub class_name: Option<String>,
|
||||
pub children: BTreeMap<String, ProjectNode>,
|
||||
pub properties: HashMap<String, UnresolvedRbxValue>,
|
||||
pub ignore_unknown_instances: Option<bool>,
|
||||
|
||||
impl Default for InstanceProjectNodeMetadata {
|
||||
fn default() -> InstanceProjectNodeMetadata {
|
||||
InstanceProjectNodeMetadata {
|
||||
ignore_unknown_instances: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ProjectNode {
|
||||
Instance(InstanceProjectNode),
|
||||
SyncPoint(SyncPointProjectNode),
|
||||
#[serde(serialize_with = "crate::path_serializer::serialize_option")]
|
||||
pub path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl ProjectNode {
|
||||
fn validate_reserved_names(&self) {
|
||||
for (name, child) in &self.children {
|
||||
if name.starts_with('$') {
|
||||
warn!("Keys starting with '$' are reserved by Rojo to ensure forward compatibility.");
|
||||
warn!("This project uses the key '{}', which should be renamed.", name);
|
||||
}
|
||||
|
||||
child.validate_reserved_names();
|
||||
}
|
||||
}
|
||||
|
||||
fn to_source_node(&self, project_file_location: &Path) -> SourceProjectNode {
|
||||
match self {
|
||||
ProjectNode::Instance(node) => {
|
||||
let mut children = HashMap::new();
|
||||
let children = self.children.iter()
|
||||
.map(|(key, value)| (key.clone(), value.to_source_node(project_file_location)))
|
||||
.collect();
|
||||
|
||||
for (key, child) in &node.children {
|
||||
children.insert(key.clone(), child.to_source_node(project_file_location));
|
||||
}
|
||||
// If paths are relative to the project file, transform them to look
|
||||
// Unixy and write relative paths instead.
|
||||
//
|
||||
// This isn't perfect, since it means that paths like .. will stay as
|
||||
// absolute paths and make projects non-portable. Fixing this probably
|
||||
// means keeping the paths relative in the project format and making
|
||||
// everywhere else in Rojo do the resolution locally.
|
||||
let path = self.path.as_ref().map(|path| {
|
||||
let project_folder_location = project_file_location.parent().unwrap();
|
||||
|
||||
SourceProjectNode::Instance {
|
||||
class_name: node.class_name.clone(),
|
||||
children,
|
||||
properties: node.properties.clone(),
|
||||
ignore_unknown_instances: node.metadata.ignore_unknown_instances,
|
||||
}
|
||||
},
|
||||
ProjectNode::SyncPoint(sync_node) => {
|
||||
let project_folder_location = project_file_location.parent().unwrap();
|
||||
match path.strip_prefix(project_folder_location) {
|
||||
Ok(stripped) => stripped.to_str().unwrap().replace("\\", "/"),
|
||||
Err(_) => format!("{}", path.display()),
|
||||
}
|
||||
});
|
||||
|
||||
let friendly_path = match sync_node.path.strip_prefix(project_folder_location) {
|
||||
Ok(stripped) => stripped.to_str().unwrap().replace("\\", "/"),
|
||||
Err(_) => format!("{}", sync_node.path.display()),
|
||||
};
|
||||
|
||||
SourceProjectNode::SyncPoint {
|
||||
path: friendly_path,
|
||||
}
|
||||
},
|
||||
SourceProjectNode {
|
||||
class_name: self.class_name.clone(),
|
||||
properties: self.properties.clone(),
|
||||
ignore_unknown_instances: self.ignore_unknown_instances,
|
||||
children,
|
||||
path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstanceProjectNode {
|
||||
pub class_name: String,
|
||||
pub children: HashMap<String, ProjectNode>,
|
||||
pub properties: HashMap<String, RbxValue>,
|
||||
pub metadata: InstanceProjectNodeMetadata,
|
||||
pub struct Plugin {
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncPointProjectNode {
|
||||
pub path: PathBuf,
|
||||
impl Plugin {
|
||||
fn to_source_plugin(&self, project_file_location: &Path) -> SourcePlugin {
|
||||
let project_folder_location = project_file_location.parent().unwrap();
|
||||
let path = match self.path.strip_prefix(project_folder_location) {
|
||||
Ok(stripped) => stripped.to_str().unwrap().replace("\\", "/"),
|
||||
Err(_) => format!("{}", self.path.display()),
|
||||
};
|
||||
|
||||
SourcePlugin {
|
||||
path,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Project {
|
||||
pub name: String,
|
||||
pub tree: ProjectNode,
|
||||
pub plugins: Vec<Plugin>,
|
||||
pub serve_port: Option<u16>,
|
||||
pub serve_place_ids: Option<HashSet<u64>>,
|
||||
pub file_location: PathBuf,
|
||||
@@ -255,49 +347,23 @@ pub struct Project {
|
||||
|
||||
impl Project {
|
||||
pub fn init_place(project_fuzzy_path: &Path) -> Result<PathBuf, ProjectInitError> {
|
||||
let project_path = Project::init_pick_path(project_fuzzy_path)?;
|
||||
let project_folder_path = project_path.parent().unwrap();
|
||||
let project_path = Project::pick_path_for_init(project_fuzzy_path)?;
|
||||
|
||||
let project_name = if project_fuzzy_path == project_path {
|
||||
project_fuzzy_path.parent().unwrap().file_name().unwrap().to_str().unwrap()
|
||||
project_fuzzy_path
|
||||
.parent().expect("Path did not have a parent directory")
|
||||
.file_name().expect("Path did not have a file name")
|
||||
.to_str().expect("Path had invalid Unicode")
|
||||
} else {
|
||||
project_fuzzy_path.file_name().unwrap().to_str().unwrap()
|
||||
project_fuzzy_path
|
||||
.file_name().expect("Path did not have a file name")
|
||||
.to_str().expect("Path had invalid Unicode")
|
||||
};
|
||||
|
||||
let tree = ProjectNode::Instance(InstanceProjectNode {
|
||||
class_name: "DataModel".to_string(),
|
||||
children: hashmap! {
|
||||
String::from("ReplicatedStorage") => ProjectNode::Instance(InstanceProjectNode {
|
||||
class_name: String::from("ReplicatedStorage"),
|
||||
children: hashmap! {
|
||||
String::from("Source") => ProjectNode::SyncPoint(SyncPointProjectNode {
|
||||
path: project_folder_path.join("src"),
|
||||
}),
|
||||
},
|
||||
properties: HashMap::new(),
|
||||
metadata: Default::default(),
|
||||
}),
|
||||
String::from("HttpService") => ProjectNode::Instance(InstanceProjectNode {
|
||||
class_name: String::from("HttpService"),
|
||||
children: HashMap::new(),
|
||||
properties: hashmap! {
|
||||
String::from("HttpEnabled") => RbxValue::Bool {
|
||||
value: true,
|
||||
},
|
||||
},
|
||||
metadata: Default::default(),
|
||||
}),
|
||||
},
|
||||
properties: HashMap::new(),
|
||||
metadata: Default::default(),
|
||||
});
|
||||
let mut project = Project::load_from_str(DEFAULT_PLACE, &project_path)
|
||||
.map_err(ProjectInitError::JsonError)?;
|
||||
|
||||
let project = Project {
|
||||
name: project_name.to_string(),
|
||||
tree,
|
||||
serve_port: None,
|
||||
serve_place_ids: None,
|
||||
file_location: project_path.clone(),
|
||||
};
|
||||
project.name = project_name.to_owned();
|
||||
|
||||
project.save()
|
||||
.map_err(ProjectInitError::SaveError)?;
|
||||
@@ -306,21 +372,31 @@ impl Project {
|
||||
}
|
||||
|
||||
pub fn init_model(project_fuzzy_path: &Path) -> Result<PathBuf, ProjectInitError> {
|
||||
let project_path = Project::init_pick_path(project_fuzzy_path)?;
|
||||
let project_folder_path = project_path.parent().unwrap();
|
||||
let project_path = Project::pick_path_for_init(project_fuzzy_path)?;
|
||||
|
||||
let project_name = if project_fuzzy_path == project_path {
|
||||
project_fuzzy_path.parent().unwrap().file_name().unwrap().to_str().unwrap()
|
||||
project_fuzzy_path
|
||||
.parent().expect("Path did not have a parent directory")
|
||||
.file_name().expect("Path did not have a file name")
|
||||
.to_str().expect("Path had invalid Unicode")
|
||||
} else {
|
||||
project_fuzzy_path.file_name().unwrap().to_str().unwrap()
|
||||
project_fuzzy_path
|
||||
.file_name().expect("Path did not have a file name")
|
||||
.to_str().expect("Path had invalid Unicode")
|
||||
};
|
||||
|
||||
let tree = ProjectNode::SyncPoint(SyncPointProjectNode {
|
||||
path: project_folder_path.join("src"),
|
||||
});
|
||||
let project_folder_path = project_path
|
||||
.parent().expect("Path did not have a parent directory");
|
||||
|
||||
let tree = ProjectNode {
|
||||
path: Some(project_folder_path.join("src")),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let project = Project {
|
||||
name: project_name.to_string(),
|
||||
tree,
|
||||
plugins: Vec::new(),
|
||||
serve_port: None,
|
||||
serve_place_ids: None,
|
||||
file_location: project_path.clone(),
|
||||
@@ -332,7 +408,7 @@ impl Project {
|
||||
Ok(project_path)
|
||||
}
|
||||
|
||||
fn init_pick_path(project_fuzzy_path: &Path) -> Result<PathBuf, ProjectInitError> {
|
||||
fn pick_path_for_init(project_fuzzy_path: &Path) -> Result<PathBuf, ProjectInitError> {
|
||||
let is_exact = project_fuzzy_path.extension().is_some();
|
||||
|
||||
let project_path = if is_exact {
|
||||
@@ -352,7 +428,7 @@ impl Project {
|
||||
Ok(project_path)
|
||||
}
|
||||
|
||||
pub fn locate(start_location: &Path) -> Option<PathBuf> {
|
||||
fn locate(start_location: &Path) -> Option<PathBuf> {
|
||||
// TODO: Check for specific error kinds, convert 'not found' to Result.
|
||||
let location_metadata = fs::metadata(start_location).ok()?;
|
||||
|
||||
@@ -362,11 +438,17 @@ impl Project {
|
||||
} else if location_metadata.is_dir() {
|
||||
let with_file = start_location.join(PROJECT_FILENAME);
|
||||
|
||||
if let Ok(with_file_metadata) = fs::metadata(&with_file) {
|
||||
if with_file_metadata.is_file() {
|
||||
if let Ok(file_metadata) = fs::metadata(&with_file) {
|
||||
if file_metadata.is_file() {
|
||||
return Some(with_file);
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
let with_compat_file = start_location.join(COMPAT_PROJECT_FILENAME);
|
||||
|
||||
if let Ok(file_metadata) = fs::metadata(&with_compat_file) {
|
||||
if file_metadata.is_file() {
|
||||
return Some(with_compat_file);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -377,23 +459,43 @@ impl Project {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_fuzzy(fuzzy_project_location: &Path) -> Result<Project, ProjectLoadFuzzyError> {
|
||||
let project_path = Self::locate(fuzzy_project_location)
|
||||
.ok_or(ProjectLoadFuzzyError::NotFound)?;
|
||||
|
||||
Self::load_exact(&project_path).map_err(From::from)
|
||||
}
|
||||
|
||||
pub fn load_exact(project_file_location: &Path) -> Result<Project, ProjectLoadExactError> {
|
||||
let contents = fs::read_to_string(project_file_location)
|
||||
.map_err(ProjectLoadExactError::IoError)?;
|
||||
|
||||
let parsed: SourceProject = serde_json::from_str(&contents)
|
||||
.map_err(ProjectLoadExactError::JsonError)?;
|
||||
fn load_from_str(contents: &str, project_file_location: &Path) -> Result<Project, serde_json::Error> {
|
||||
let parsed: SourceProject = serde_json::from_str(&contents)?;
|
||||
|
||||
Ok(parsed.into_project(project_file_location))
|
||||
}
|
||||
|
||||
pub fn load_fuzzy(fuzzy_project_location: &Path) -> Result<Project, ProjectLoadError> {
|
||||
if let Some(project_path) = Self::locate(fuzzy_project_location) {
|
||||
Self::load_exact(&project_path)
|
||||
} else {
|
||||
Project::warn_if_4x_project_present(fuzzy_project_location);
|
||||
Err(ProjectLoadError::NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_exact(project_file_location: &Path) -> Result<Project, ProjectLoadError> {
|
||||
let contents = fs::read_to_string(project_file_location)
|
||||
.map_err(|error| match error.kind() {
|
||||
io::ErrorKind::NotFound => ProjectLoadError::NotFound,
|
||||
_ => ProjectLoadError::Io {
|
||||
inner: error,
|
||||
path: project_file_location.to_path_buf(),
|
||||
}
|
||||
})?;
|
||||
|
||||
let parsed: SourceProject = serde_json::from_str(&contents)
|
||||
.map_err(|error| ProjectLoadError::Json {
|
||||
inner: error,
|
||||
path: project_file_location.to_path_buf(),
|
||||
})?;
|
||||
|
||||
let project = parsed.into_project(project_file_location);
|
||||
project.check_compatibility();
|
||||
|
||||
Ok(project)
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<(), ProjectSaveError> {
|
||||
let source_project = self.to_source_project();
|
||||
let mut file = File::create(&self.file_location)
|
||||
@@ -405,10 +507,56 @@ impl Project {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Checks if there are any compatibility issues with this project file and
|
||||
/// warns the user if there are any.
|
||||
fn check_compatibility(&self) {
|
||||
let file_name = self.file_location
|
||||
.file_name().expect("Project file path did not have a file name")
|
||||
.to_str().expect("Project file path was not valid Unicode");
|
||||
|
||||
if file_name == COMPAT_PROJECT_FILENAME {
|
||||
warn!("Rojo's default project file name changed in 0.5.0-alpha3.");
|
||||
warn!("Support for the old project file name will be dropped before 0.5.0 releases.");
|
||||
warn!("Your project file is named {}", COMPAT_PROJECT_FILENAME);
|
||||
warn!("Rename your project file to {}", PROJECT_FILENAME);
|
||||
} else if !file_name.ends_with(".project.json") {
|
||||
warn!("Starting in Rojo 0.5.0-alpha3, it's recommended to give all project files the");
|
||||
warn!(".project.json extension. This helps Rojo differentiate project files from");
|
||||
warn!("other JSON files!");
|
||||
}
|
||||
|
||||
self.tree.validate_reserved_names();
|
||||
}
|
||||
|
||||
/// Issues a warning if no Rojo 0.5.x project is found, but there's a legacy
|
||||
/// 0.4.x project in the directory.
|
||||
fn warn_if_4x_project_present(folder: &Path) {
|
||||
let file_path = folder.join("rojo.json");
|
||||
|
||||
if fs::metadata(file_path).is_ok() {
|
||||
warn!("No Rojo 0.5 project file was found, but a Rojo 0.4 project was.");
|
||||
warn!("Rojo 0.5.x uses 'default.project.json' files");
|
||||
warn!("Rojo 0.5.x uses 'rojo.json' files");
|
||||
warn!("");
|
||||
warn!("For help upgrading, see:");
|
||||
warn!("https://lpghatguy.github.io/rojo/guide/migrating-to-epiphany/");
|
||||
}
|
||||
}
|
||||
|
||||
pub fn folder_location(&self) -> &Path {
|
||||
self.file_location.parent().unwrap()
|
||||
}
|
||||
|
||||
fn to_source_project(&self) -> SourceProject {
|
||||
let plugins = self.plugins
|
||||
.iter()
|
||||
.map(|plugin| plugin.to_source_plugin(&self.file_location))
|
||||
.collect();
|
||||
|
||||
SourceProject {
|
||||
name: self.name.clone(),
|
||||
tree: self.tree.to_source_node(&self.file_location),
|
||||
plugins,
|
||||
serve_port: self.serve_port,
|
||||
serve_place_ids: self.serve_place_ids.clone(),
|
||||
}
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
collections::HashMap,
|
||||
collections::{HashSet, HashMap},
|
||||
path::{Path, PathBuf},
|
||||
str,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use serde_derive::{Serialize, Deserialize};
|
||||
use log::{info, trace};
|
||||
use rbx_tree::{RbxTree, RbxId};
|
||||
use rlua::Lua;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use log::{info, trace, error};
|
||||
use rbx_dom_weak::{RbxTree, RbxId};
|
||||
|
||||
use crate::{
|
||||
project::Project,
|
||||
project::{Project, ProjectNode},
|
||||
message_queue::MessageQueue,
|
||||
imfs::{Imfs, ImfsItem},
|
||||
path_map::PathMap,
|
||||
rbx_snapshot::{SnapshotContext, snapshot_project_tree, snapshot_imfs_path},
|
||||
rbx_snapshot::{
|
||||
SnapshotError,
|
||||
SnapshotContext,
|
||||
SnapshotPluginContext,
|
||||
SnapshotPluginEntry,
|
||||
snapshot_project_tree,
|
||||
snapshot_project_node,
|
||||
snapshot_imfs_path,
|
||||
},
|
||||
snapshot_reconciler::{InstanceChanges, reify_root, reconcile_subtree},
|
||||
};
|
||||
|
||||
@@ -23,24 +32,31 @@ const INIT_SCRIPT: &str = "init.lua";
|
||||
const INIT_SERVER_SCRIPT: &str = "init.server.lua";
|
||||
const INIT_CLIENT_SCRIPT: &str = "init.client.lua";
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct MetadataPerPath {
|
||||
pub instance_id: Option<RbxId>,
|
||||
pub instance_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
/// `source_path` or `project_definition` or both must both be Some.
|
||||
#[derive(Debug, Clone, PartialEq, Default, Serialize, Deserialize)]
|
||||
pub struct MetadataPerInstance {
|
||||
pub source_path: Option<PathBuf>,
|
||||
pub ignore_unknown_instances: bool,
|
||||
|
||||
/// The path on the filesystem that the instance was read from the
|
||||
/// filesystem if it came from the filesystem.
|
||||
#[serde(serialize_with = "crate::path_serializer::serialize_option")]
|
||||
pub source_path: Option<PathBuf>,
|
||||
|
||||
/// Information about the instance that came from the project that defined
|
||||
/// it, if that's where it was defined.
|
||||
///
|
||||
/// A key-value pair where the key should be the name of the instance and
|
||||
/// the value is the ProjectNode from the instance's project.
|
||||
pub project_definition: Option<(String, ProjectNode)>,
|
||||
}
|
||||
|
||||
/// Contains all of the state needed to update an `RbxTree` in real time using
|
||||
/// the in-memory filesystem, as well as messaging to Rojo clients what
|
||||
/// instances have actually updated at any point.
|
||||
pub struct RbxSession {
|
||||
tree: RbxTree,
|
||||
|
||||
// TODO(#105): Change metadata_per_path to PathMap<Vec<MetadataPerPath>> for
|
||||
// path aliasing.
|
||||
metadata_per_path: PathMap<MetadataPerPath>,
|
||||
instances_per_path: PathMap<HashSet<RbxId>>,
|
||||
metadata_per_instance: HashMap<RbxId, MetadataPerInstance>,
|
||||
message_queue: Arc<MessageQueue<InstanceChanges>>,
|
||||
imfs: Arc<Mutex<Imfs>>,
|
||||
@@ -51,22 +67,60 @@ impl RbxSession {
|
||||
project: Arc<Project>,
|
||||
imfs: Arc<Mutex<Imfs>>,
|
||||
message_queue: Arc<MessageQueue<InstanceChanges>>,
|
||||
) -> RbxSession {
|
||||
let mut metadata_per_path = PathMap::new();
|
||||
) -> Result<RbxSession, SnapshotError> {
|
||||
let mut instances_per_path = PathMap::new();
|
||||
let mut metadata_per_instance = HashMap::new();
|
||||
|
||||
let plugin_context = if cfg!(feature = "server-plugins") {
|
||||
let lua = Lua::new();
|
||||
let mut callback_key = None;
|
||||
|
||||
lua.context(|context| {
|
||||
let callback = context.load(r#"
|
||||
return function(snapshot)
|
||||
print("got my snapshot:", snapshot)
|
||||
print("name:", snapshot.name, "class name:", snapshot.className)
|
||||
end"#)
|
||||
.set_name("a cool plugin").unwrap()
|
||||
.call::<(), rlua::Function>(()).unwrap();
|
||||
|
||||
callback_key = Some(context.create_registry_value(callback).unwrap());
|
||||
});
|
||||
|
||||
let plugins = vec![
|
||||
SnapshotPluginEntry {
|
||||
file_name_filter: String::new(),
|
||||
callback: callback_key.unwrap(),
|
||||
}
|
||||
];
|
||||
|
||||
Some(SnapshotPluginContext { lua, plugins })
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let context = SnapshotContext {
|
||||
plugin_context,
|
||||
};
|
||||
|
||||
let tree = {
|
||||
let temp_imfs = imfs.lock().unwrap();
|
||||
reify_initial_tree(&project, &temp_imfs, &mut metadata_per_path, &mut metadata_per_instance)
|
||||
reify_initial_tree(
|
||||
&project,
|
||||
&context,
|
||||
&temp_imfs,
|
||||
&mut instances_per_path,
|
||||
&mut metadata_per_instance,
|
||||
)?
|
||||
};
|
||||
|
||||
RbxSession {
|
||||
Ok(RbxSession {
|
||||
tree,
|
||||
metadata_per_path,
|
||||
instances_per_path,
|
||||
metadata_per_instance,
|
||||
message_queue,
|
||||
imfs,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn path_created_or_updated(&mut self, path: &Path) {
|
||||
@@ -80,7 +134,7 @@ impl RbxSession {
|
||||
.expect("Path was outside in-memory filesystem roots");
|
||||
|
||||
// Find the closest instance in the tree that currently exists
|
||||
let mut path_to_snapshot = self.metadata_per_path.descend(root_path, path);
|
||||
let mut path_to_snapshot = self.instances_per_path.descend(root_path, path);
|
||||
|
||||
// If this is a file that might affect its parent if modified, we
|
||||
// should snapshot its parent instead.
|
||||
@@ -93,42 +147,54 @@ impl RbxSession {
|
||||
|
||||
trace!("Snapshotting path {}", path_to_snapshot.display());
|
||||
|
||||
let path_metadata = self.metadata_per_path.get(&path_to_snapshot).unwrap();
|
||||
let instances_at_path = self.instances_per_path.get(&path_to_snapshot)
|
||||
.expect("Metadata did not exist for path")
|
||||
.clone();
|
||||
|
||||
trace!("Metadata for path: {:?}", path_metadata);
|
||||
|
||||
let instance_id = path_metadata.instance_id
|
||||
.expect("Instance did not exist in tree");
|
||||
|
||||
// If this instance is a sync point, pull its name out of our
|
||||
// per-path metadata store.
|
||||
let instance_name = path_metadata.instance_name.as_ref()
|
||||
.map(|value| Cow::Owned(value.to_owned()));
|
||||
|
||||
let mut context = SnapshotContext {
|
||||
metadata_per_path: &mut self.metadata_per_path,
|
||||
};
|
||||
let maybe_snapshot = snapshot_imfs_path(&imfs, &mut context, &path_to_snapshot, instance_name)
|
||||
.unwrap_or_else(|_| panic!("Could not generate instance snapshot for path {}", path_to_snapshot.display()));
|
||||
|
||||
let snapshot = match maybe_snapshot {
|
||||
Some(snapshot) => snapshot,
|
||||
None => {
|
||||
trace!("Path resulted in no snapshot being generated.");
|
||||
return;
|
||||
},
|
||||
let context = SnapshotContext {
|
||||
plugin_context: None,
|
||||
};
|
||||
|
||||
trace!("Snapshot: {:#?}", snapshot);
|
||||
for instance_id in &instances_at_path {
|
||||
let instance_metadata = self.metadata_per_instance.get(&instance_id)
|
||||
.expect("Metadata for instance ID did not exist");
|
||||
|
||||
reconcile_subtree(
|
||||
&mut self.tree,
|
||||
instance_id,
|
||||
&snapshot,
|
||||
&mut self.metadata_per_path,
|
||||
&mut self.metadata_per_instance,
|
||||
&mut changes,
|
||||
);
|
||||
let maybe_snapshot = match &instance_metadata.project_definition {
|
||||
Some((instance_name, project_node)) => {
|
||||
snapshot_project_node(&context, &imfs, &project_node, Cow::Owned(instance_name.clone()))
|
||||
// .unwrap_or_else(|_| panic!("Could not generate instance snapshot for path {}", path_to_snapshot.display()))
|
||||
},
|
||||
None => {
|
||||
snapshot_imfs_path(&context, &imfs, &path_to_snapshot, None)
|
||||
// .unwrap_or_else(|_| panic!("Could not generate instance snapshot for path {}", path_to_snapshot.display()))
|
||||
},
|
||||
};
|
||||
|
||||
let snapshot = match maybe_snapshot {
|
||||
Ok(Some(snapshot)) => snapshot,
|
||||
Ok(None) => {
|
||||
trace!("Path resulted in no snapshot being generated.");
|
||||
return;
|
||||
},
|
||||
Err(err) => {
|
||||
error!("Rojo couldn't turn one of the project's files into Roblox instances.");
|
||||
error!("Any changes to the file have been ignored.");
|
||||
error!("{}", err);
|
||||
return;
|
||||
},
|
||||
};
|
||||
|
||||
trace!("Snapshot: {:#?}", snapshot);
|
||||
|
||||
reconcile_subtree(
|
||||
&mut self.tree,
|
||||
*instance_id,
|
||||
&snapshot,
|
||||
&mut self.instances_per_path,
|
||||
&mut self.metadata_per_instance,
|
||||
&mut changes,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if changes.is_empty() {
|
||||
@@ -153,10 +219,14 @@ impl RbxSession {
|
||||
// If the path doesn't exist or is a directory, we don't care if it
|
||||
// updated
|
||||
match imfs.get(path) {
|
||||
Some(ImfsItem::Directory(_)) | None => {
|
||||
Some(ImfsItem::Directory(_)) => {
|
||||
trace!("Updated path was a directory, ignoring.");
|
||||
return;
|
||||
},
|
||||
None => {
|
||||
trace!("Updated path did not exist in IMFS, ignoring.");
|
||||
return;
|
||||
},
|
||||
Some(ImfsItem::File(_)) => {},
|
||||
}
|
||||
}
|
||||
@@ -166,13 +236,13 @@ impl RbxSession {
|
||||
|
||||
pub fn path_removed(&mut self, path: &Path) {
|
||||
info!("Path removed: {}", path.display());
|
||||
self.metadata_per_path.remove(path);
|
||||
self.instances_per_path.remove(path);
|
||||
self.path_created_or_updated(path);
|
||||
}
|
||||
|
||||
pub fn path_renamed(&mut self, from_path: &Path, to_path: &Path) {
|
||||
info!("Path renamed from {} to {}", from_path.display(), to_path.display());
|
||||
self.metadata_per_path.remove(from_path);
|
||||
self.instances_per_path.remove(from_path);
|
||||
self.path_created_or_updated(from_path);
|
||||
self.path_created_or_updated(to_path);
|
||||
}
|
||||
@@ -181,36 +251,39 @@ impl RbxSession {
|
||||
&self.tree
|
||||
}
|
||||
|
||||
pub fn get_all_instance_metadata(&self) -> &HashMap<RbxId, MetadataPerInstance> {
|
||||
&self.metadata_per_instance
|
||||
}
|
||||
|
||||
pub fn get_instance_metadata(&self, id: RbxId) -> Option<&MetadataPerInstance> {
|
||||
self.metadata_per_instance.get(&id)
|
||||
}
|
||||
|
||||
pub fn debug_get_metadata_per_path(&self) -> &PathMap<MetadataPerPath> {
|
||||
&self.metadata_per_path
|
||||
}
|
||||
}
|
||||
|
||||
pub fn construct_oneoff_tree(project: &Project, imfs: &Imfs) -> RbxTree {
|
||||
let mut metadata_per_path = PathMap::new();
|
||||
pub fn construct_oneoff_tree(project: &Project, imfs: &Imfs) -> Result<RbxTree, SnapshotError> {
|
||||
let mut instances_per_path = PathMap::new();
|
||||
let mut metadata_per_instance = HashMap::new();
|
||||
reify_initial_tree(project, imfs, &mut metadata_per_path, &mut metadata_per_instance)
|
||||
let context = SnapshotContext {
|
||||
plugin_context: None,
|
||||
};
|
||||
|
||||
reify_initial_tree(project, &context, imfs, &mut instances_per_path, &mut metadata_per_instance)
|
||||
}
|
||||
|
||||
fn reify_initial_tree(
|
||||
project: &Project,
|
||||
context: &SnapshotContext,
|
||||
imfs: &Imfs,
|
||||
metadata_per_path: &mut PathMap<MetadataPerPath>,
|
||||
instances_per_path: &mut PathMap<HashSet<RbxId>>,
|
||||
metadata_per_instance: &mut HashMap<RbxId, MetadataPerInstance>,
|
||||
) -> RbxTree {
|
||||
let mut context = SnapshotContext {
|
||||
metadata_per_path,
|
||||
) -> Result<RbxTree, SnapshotError> {
|
||||
let snapshot = match snapshot_project_tree(&context, imfs, project)? {
|
||||
Some(snapshot) => snapshot,
|
||||
None => panic!("Project did not produce any instances"),
|
||||
};
|
||||
let snapshot = snapshot_project_tree(imfs, &mut context, project)
|
||||
.expect("Could not snapshot project tree")
|
||||
.expect("Project did not produce any instances");
|
||||
|
||||
let mut changes = InstanceChanges::default();
|
||||
let tree = reify_root(&snapshot, metadata_per_path, metadata_per_instance, &mut changes);
|
||||
let tree = reify_root(&snapshot, instances_per_path, metadata_per_instance, &mut changes);
|
||||
|
||||
tree
|
||||
Ok(tree)
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
//! Defines how Rojo transforms files into instances through the snapshot
|
||||
//! system.
|
||||
|
||||
use std::{
|
||||
borrow::Cow,
|
||||
collections::HashMap,
|
||||
@@ -6,10 +9,13 @@ use std::{
|
||||
str,
|
||||
};
|
||||
|
||||
use serde_derive::{Serialize, Deserialize};
|
||||
use maplit::hashmap;
|
||||
use rbx_tree::{RbxTree, RbxValue, RbxInstanceProperties};
|
||||
use rlua::Lua;
|
||||
use failure::Fail;
|
||||
use log::info;
|
||||
use maplit::hashmap;
|
||||
use rbx_dom_weak::{RbxTree, RbxValue, RbxInstanceProperties, UnresolvedRbxValue};
|
||||
use serde::{Serialize, Deserialize};
|
||||
use rbx_reflection::{try_resolve_value, ValueResolveError};
|
||||
|
||||
use crate::{
|
||||
imfs::{
|
||||
@@ -21,28 +27,68 @@ use crate::{
|
||||
project::{
|
||||
Project,
|
||||
ProjectNode,
|
||||
InstanceProjectNode,
|
||||
SyncPointProjectNode,
|
||||
},
|
||||
snapshot_reconciler::{
|
||||
RbxSnapshotInstance,
|
||||
snapshot_from_tree,
|
||||
},
|
||||
path_map::PathMap,
|
||||
// TODO: Move MetadataPerPath into this module?
|
||||
rbx_session::{MetadataPerPath, MetadataPerInstance},
|
||||
// TODO: Move MetadataPerInstance into this module?
|
||||
rbx_session::MetadataPerInstance,
|
||||
};
|
||||
|
||||
const INIT_MODULE_NAME: &str = "init.lua";
|
||||
const INIT_SERVER_NAME: &str = "init.server.lua";
|
||||
const INIT_CLIENT_NAME: &str = "init.client.lua";
|
||||
|
||||
pub type SnapshotResult<'a> = Result<Option<RbxSnapshotInstance<'a>>, SnapshotError>;
|
||||
|
||||
pub struct SnapshotContext<'meta> {
|
||||
pub metadata_per_path: &'meta mut PathMap<MetadataPerPath>,
|
||||
pub struct SnapshotContext {
|
||||
pub plugin_context: Option<SnapshotPluginContext>,
|
||||
}
|
||||
|
||||
/// Context that's only relevant to generating snapshots if there are plugins
|
||||
/// associated with the project.
|
||||
///
|
||||
/// It's possible that this needs some sort of extra nesting/filtering to
|
||||
/// support nested projects, since their plugins should only apply to
|
||||
/// themselves.
|
||||
pub struct SnapshotPluginContext {
|
||||
pub lua: Lua,
|
||||
pub plugins: Vec<SnapshotPluginEntry>,
|
||||
}
|
||||
|
||||
pub struct SnapshotPluginEntry {
|
||||
/// Simple file name suffix filter to avoid running plugins on every file
|
||||
/// change.
|
||||
pub file_name_filter: String,
|
||||
|
||||
/// A key into the Lua registry created by [`create_registry_value`] that
|
||||
/// refers to a function that can be called to transform a file/instance
|
||||
/// pair according to how the plugin needs to operate.
|
||||
///
|
||||
/// [`create_registry_value`]: https://docs.rs/rlua/0.16.2/rlua/struct.Context.html#method.create_registry_value
|
||||
pub callback: rlua::RegistryKey,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct LuaRbxSnapshot(RbxSnapshotInstance<'static>);
|
||||
|
||||
impl rlua::UserData for LuaRbxSnapshot {
|
||||
fn add_methods<'lua, M: rlua::UserDataMethods<'lua, Self>>(methods: &mut M) {
|
||||
methods.add_meta_method(rlua::MetaMethod::Index, |_context, this, key: String| {
|
||||
match key.as_str() {
|
||||
"name" => Ok(this.0.name.clone().into_owned()),
|
||||
"className" => Ok(this.0.class_name.clone().into_owned()),
|
||||
_ => Err(rlua::Error::RuntimeError(format!("{} is not a valid member of RbxSnapshotInstance", &key))),
|
||||
}
|
||||
});
|
||||
|
||||
methods.add_meta_method(rlua::MetaMethod::ToString, |_context, _this, _args: ()| {
|
||||
Ok("RbxSnapshotInstance")
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub type SnapshotResult<'a> = Result<Option<RbxSnapshotInstance<'a>>, SnapshotError>;
|
||||
|
||||
#[derive(Debug, Fail)]
|
||||
pub enum SnapshotError {
|
||||
DidNotExist(PathBuf),
|
||||
@@ -53,7 +99,29 @@ pub enum SnapshotError {
|
||||
path: PathBuf,
|
||||
},
|
||||
|
||||
JsonModelDecodeError {
|
||||
#[fail(cause)]
|
||||
inner: serde_json::Error,
|
||||
path: PathBuf,
|
||||
},
|
||||
|
||||
ExtraMetadataError {
|
||||
#[fail(cause)]
|
||||
inner: serde_json::Error,
|
||||
path: PathBuf,
|
||||
},
|
||||
|
||||
InvalidMetadataModelField {
|
||||
field_name: String,
|
||||
path: PathBuf,
|
||||
},
|
||||
|
||||
MetadataClassNameNonInit {
|
||||
path: PathBuf,
|
||||
},
|
||||
|
||||
XmlModelDecodeError {
|
||||
#[fail(cause)]
|
||||
inner: rbx_xml::DecodeError,
|
||||
path: PathBuf,
|
||||
},
|
||||
@@ -62,6 +130,31 @@ pub enum SnapshotError {
|
||||
inner: rbx_binary::DecodeError,
|
||||
path: PathBuf,
|
||||
},
|
||||
|
||||
CsvDecodeError {
|
||||
#[fail(cause)]
|
||||
inner: csv::Error,
|
||||
path: PathBuf,
|
||||
},
|
||||
|
||||
ProjectNodeUnusable,
|
||||
|
||||
ProjectNodeInvalidTransmute {
|
||||
partition_path: PathBuf,
|
||||
},
|
||||
|
||||
PropertyResolveError {
|
||||
#[fail(cause)]
|
||||
inner: ValueResolveError,
|
||||
},
|
||||
}
|
||||
|
||||
impl From<ValueResolveError> for SnapshotError {
|
||||
fn from(inner: ValueResolveError) -> SnapshotError {
|
||||
SnapshotError::PropertyResolveError {
|
||||
inner,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for SnapshotError {
|
||||
@@ -71,113 +164,161 @@ impl fmt::Display for SnapshotError {
|
||||
SnapshotError::Utf8Error { inner, path } => {
|
||||
write!(output, "Invalid UTF-8: {} in path {}", inner, path.display())
|
||||
},
|
||||
SnapshotError::JsonModelDecodeError { inner, path } => {
|
||||
write!(output, "Malformed .model.json model: {} in path {}", inner, path.display())
|
||||
},
|
||||
SnapshotError::ExtraMetadataError { inner, path } => {
|
||||
write!(output, "Malformed init.meta.json: {} in path {}", inner, path.display())
|
||||
},
|
||||
SnapshotError::InvalidMetadataModelField { field_name, path } => {
|
||||
writeln!(output, "The field '{}' cannot be specified on .meta.json files attached to models.", field_name)?;
|
||||
writeln!(output, "Model path: {}", path.display())
|
||||
},
|
||||
SnapshotError::MetadataClassNameNonInit { path } => {
|
||||
writeln!(output, "The field 'className' cannot be specified on .meta.json files besides init.meta.json")?;
|
||||
writeln!(output, "Model path: {}", path.display())
|
||||
},
|
||||
SnapshotError::XmlModelDecodeError { inner, path } => {
|
||||
write!(output, "Malformed rbxmx model: {:?} in path {}", inner, path.display())
|
||||
write!(output, "Malformed rbxmx model: {} in path {}", inner, path.display())
|
||||
},
|
||||
SnapshotError::BinaryModelDecodeError { inner, path } => {
|
||||
write!(output, "Malformed rbxm model: {:?} in path {}", inner, path.display())
|
||||
},
|
||||
SnapshotError::CsvDecodeError { inner, path } => {
|
||||
write!(output, "Malformed csv file: {} in path {}", inner, path.display())
|
||||
},
|
||||
SnapshotError::ProjectNodeUnusable => {
|
||||
write!(output, "Rojo project nodes must specify either $path or $className.")
|
||||
},
|
||||
SnapshotError::ProjectNodeInvalidTransmute { partition_path } => {
|
||||
writeln!(output, "Rojo project nodes that specify both $path and $className require that the")?;
|
||||
writeln!(output, "instance produced by the files pointed to by $path has a ClassName of")?;
|
||||
writeln!(output, "Folder.")?;
|
||||
writeln!(output, "")?;
|
||||
writeln!(output, "Partition target ($path): {}", partition_path.display())
|
||||
},
|
||||
SnapshotError::PropertyResolveError { inner } => write!(output, "{}", inner),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn snapshot_project_tree<'source>(
|
||||
context: &SnapshotContext,
|
||||
imfs: &'source Imfs,
|
||||
context: &mut SnapshotContext,
|
||||
project: &'source Project,
|
||||
) -> SnapshotResult<'source> {
|
||||
snapshot_project_node(imfs, context, &project.tree, Cow::Borrowed(&project.name))
|
||||
snapshot_project_node(context, imfs, &project.tree, Cow::Borrowed(&project.name))
|
||||
}
|
||||
|
||||
fn snapshot_project_node<'source>(
|
||||
pub fn snapshot_project_node<'source>(
|
||||
context: &SnapshotContext,
|
||||
imfs: &'source Imfs,
|
||||
context: &mut SnapshotContext,
|
||||
node: &'source ProjectNode,
|
||||
node: &ProjectNode,
|
||||
instance_name: Cow<'source, str>,
|
||||
) -> SnapshotResult<'source> {
|
||||
match node {
|
||||
ProjectNode::Instance(instance_node) => snapshot_instance_node(imfs, context, instance_node, instance_name),
|
||||
ProjectNode::SyncPoint(sync_node) => snapshot_sync_point_node(imfs, context, sync_node, instance_name),
|
||||
}
|
||||
}
|
||||
let maybe_snapshot = match &node.path {
|
||||
Some(path) => snapshot_imfs_path(context, imfs, &path, Some(instance_name.clone()))?,
|
||||
None => match &node.class_name {
|
||||
Some(_class_name) => Some(RbxSnapshotInstance {
|
||||
name: instance_name.clone(),
|
||||
|
||||
fn snapshot_instance_node<'source>(
|
||||
imfs: &'source Imfs,
|
||||
context: &mut SnapshotContext,
|
||||
node: &'source InstanceProjectNode,
|
||||
instance_name: Cow<'source, str>,
|
||||
) -> SnapshotResult<'source> {
|
||||
let mut children = Vec::new();
|
||||
|
||||
for (child_name, child_project_node) in &node.children {
|
||||
if let Some(child) = snapshot_project_node(imfs, context, child_project_node, Cow::Borrowed(child_name))? {
|
||||
children.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Some(RbxSnapshotInstance {
|
||||
class_name: Cow::Borrowed(&node.class_name),
|
||||
name: instance_name,
|
||||
properties: node.properties.clone(),
|
||||
children,
|
||||
metadata: MetadataPerInstance {
|
||||
source_path: None,
|
||||
ignore_unknown_instances: node.metadata.ignore_unknown_instances,
|
||||
// These properties are replaced later in the function to
|
||||
// reduce code duplication.
|
||||
class_name: Cow::Borrowed("Folder"),
|
||||
properties: HashMap::new(),
|
||||
children: Vec::new(),
|
||||
metadata: MetadataPerInstance {
|
||||
source_path: None,
|
||||
ignore_unknown_instances: true,
|
||||
project_definition: None,
|
||||
},
|
||||
}),
|
||||
None => {
|
||||
return Err(SnapshotError::ProjectNodeUnusable);
|
||||
},
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
fn snapshot_sync_point_node<'source>(
|
||||
imfs: &'source Imfs,
|
||||
context: &mut SnapshotContext,
|
||||
node: &'source SyncPointProjectNode,
|
||||
instance_name: Cow<'source, str>,
|
||||
) -> SnapshotResult<'source> {
|
||||
let maybe_snapshot = snapshot_imfs_path(imfs, context, &node.path, Some(instance_name))?;
|
||||
};
|
||||
|
||||
// If the snapshot resulted in no instances, like if it targets an unknown
|
||||
// file or an empty model file, we can early-return.
|
||||
let snapshot = match maybe_snapshot {
|
||||
//
|
||||
// In the future, we might want to issue a warning if the project also
|
||||
// specified fields like class_name, since the user will probably be
|
||||
// confused as to why nothing showed up in the tree.
|
||||
let mut snapshot = match maybe_snapshot {
|
||||
Some(snapshot) => snapshot,
|
||||
None => return Ok(None),
|
||||
None => {
|
||||
// TODO: Return some other sort of marker here instead? If a node
|
||||
// transitions from None into Some, it's possible that configuration
|
||||
// from the ProjectNode might be lost since there's nowhere to put
|
||||
// it!
|
||||
return Ok(None);
|
||||
},
|
||||
};
|
||||
|
||||
// Otherwise, we can log the name of the sync point we just snapshotted.
|
||||
let path_meta = context.metadata_per_path.entry(node.path.to_owned()).or_default();
|
||||
path_meta.instance_name = Some(snapshot.name.clone().into_owned());
|
||||
// Applies the class name specified in `class_name` from the project, if it's
|
||||
// set.
|
||||
if let Some(class_name) = &node.class_name {
|
||||
// This can only happen if `path` was specified in the project node and
|
||||
// that path represented a non-Folder instance.
|
||||
if snapshot.class_name != "Folder" {
|
||||
return Err(SnapshotError::ProjectNodeInvalidTransmute {
|
||||
partition_path: node.path.as_ref().unwrap().to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
snapshot.class_name = Cow::Owned(class_name.to_owned());
|
||||
}
|
||||
|
||||
for (child_name, child_project_node) in &node.children {
|
||||
if let Some(child) = snapshot_project_node(context, imfs, child_project_node, Cow::Owned(child_name.clone()))? {
|
||||
snapshot.children.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
for (key, value) in &node.properties {
|
||||
let resolved_value = try_resolve_value(&snapshot.class_name, key, value)?;
|
||||
snapshot.properties.insert(key.clone(), resolved_value);
|
||||
}
|
||||
|
||||
if let Some(ignore_unknown_instances) = node.ignore_unknown_instances {
|
||||
snapshot.metadata.ignore_unknown_instances = ignore_unknown_instances;
|
||||
}
|
||||
|
||||
snapshot.metadata.project_definition = Some((instance_name.into_owned(), node.clone()));
|
||||
|
||||
Ok(Some(snapshot))
|
||||
}
|
||||
|
||||
pub fn snapshot_imfs_path<'source>(
|
||||
context: &SnapshotContext,
|
||||
imfs: &'source Imfs,
|
||||
context: &mut SnapshotContext,
|
||||
path: &Path,
|
||||
instance_name: Option<Cow<'source, str>>,
|
||||
) -> SnapshotResult<'source> {
|
||||
// If the given path doesn't exist in the in-memory filesystem, we consider
|
||||
// that an error.
|
||||
match imfs.get(path) {
|
||||
Some(imfs_item) => snapshot_imfs_item(imfs, context, imfs_item, instance_name),
|
||||
Some(imfs_item) => snapshot_imfs_item(context, imfs, imfs_item, instance_name),
|
||||
None => return Err(SnapshotError::DidNotExist(path.to_owned())),
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_imfs_item<'source>(
|
||||
context: &SnapshotContext,
|
||||
imfs: &'source Imfs,
|
||||
context: &mut SnapshotContext,
|
||||
item: &'source ImfsItem,
|
||||
instance_name: Option<Cow<'source, str>>,
|
||||
) -> SnapshotResult<'source> {
|
||||
match item {
|
||||
ImfsItem::File(file) => snapshot_imfs_file(file, instance_name),
|
||||
ImfsItem::Directory(directory) => snapshot_imfs_directory(imfs, context, directory, instance_name),
|
||||
ImfsItem::File(file) => snapshot_imfs_file(context, imfs, file, instance_name),
|
||||
ImfsItem::Directory(directory) => snapshot_imfs_directory(context, imfs, directory, instance_name),
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_imfs_directory<'source>(
|
||||
context: &SnapshotContext,
|
||||
imfs: &'source Imfs,
|
||||
context: &mut SnapshotContext,
|
||||
directory: &'source ImfsDirectory,
|
||||
instance_name: Option<Cow<'source, str>>,
|
||||
) -> SnapshotResult<'source> {
|
||||
@@ -193,11 +334,11 @@ fn snapshot_imfs_directory<'source>(
|
||||
});
|
||||
|
||||
let mut snapshot = if directory.children.contains(&init_path) {
|
||||
snapshot_imfs_path(imfs, context, &init_path, Some(snapshot_name))?.unwrap()
|
||||
snapshot_imfs_path(context, imfs, &init_path, Some(snapshot_name))?.unwrap()
|
||||
} else if directory.children.contains(&init_server_path) {
|
||||
snapshot_imfs_path(imfs, context, &init_server_path, Some(snapshot_name))?.unwrap()
|
||||
snapshot_imfs_path(context, imfs, &init_server_path, Some(snapshot_name))?.unwrap()
|
||||
} else if directory.children.contains(&init_client_path) {
|
||||
snapshot_imfs_path(imfs, context, &init_client_path, Some(snapshot_name))?.unwrap()
|
||||
snapshot_imfs_path(context, imfs, &init_client_path, Some(snapshot_name))?.unwrap()
|
||||
} else {
|
||||
RbxSnapshotInstance {
|
||||
class_name: Cow::Borrowed("Folder"),
|
||||
@@ -207,10 +348,15 @@ fn snapshot_imfs_directory<'source>(
|
||||
metadata: MetadataPerInstance {
|
||||
source_path: None,
|
||||
ignore_unknown_instances: false,
|
||||
project_definition: None,
|
||||
},
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(meta) = ExtraMetadata::locate(&imfs, &directory.path.join("init"))? {
|
||||
meta.apply(&mut snapshot)?;
|
||||
}
|
||||
|
||||
snapshot.metadata.source_path = Some(directory.path.to_owned());
|
||||
|
||||
for child_path in &directory.children {
|
||||
@@ -218,24 +364,105 @@ fn snapshot_imfs_directory<'source>(
|
||||
.file_name().expect("Couldn't extract file name")
|
||||
.to_str().expect("Couldn't convert file name to UTF-8");
|
||||
|
||||
if child_name.ends_with(".meta.json") {
|
||||
// meta.json files don't turn into instances themselves, they just
|
||||
// modify other instances.
|
||||
continue;
|
||||
}
|
||||
|
||||
match child_name {
|
||||
INIT_MODULE_NAME | INIT_SERVER_NAME | INIT_CLIENT_NAME => {
|
||||
// The existence of files with these names modifies the
|
||||
// parent instance and is handled above, so we can skip
|
||||
// them here.
|
||||
},
|
||||
_ => {
|
||||
if let Some(child) = snapshot_imfs_path(imfs, context, child_path, None)? {
|
||||
snapshot.children.push(child);
|
||||
}
|
||||
},
|
||||
continue;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if let Some(child) = snapshot_imfs_path(context, imfs, child_path, None)? {
|
||||
snapshot.children.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(Some(snapshot))
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct ExtraMetadata {
|
||||
class_name: Option<String>,
|
||||
ignore_unknown_instances: Option<bool>,
|
||||
|
||||
#[serde(default = "HashMap::new")]
|
||||
properties: HashMap<String, UnresolvedRbxValue>,
|
||||
}
|
||||
|
||||
impl ExtraMetadata {
|
||||
fn apply(self, snapshot: &mut RbxSnapshotInstance) -> Result<(), SnapshotError> {
|
||||
if let Some(meta_class) = self.class_name {
|
||||
snapshot.class_name = Cow::Owned(meta_class);
|
||||
}
|
||||
|
||||
if let Some(meta_ignore_instances) = self.ignore_unknown_instances {
|
||||
snapshot.metadata.ignore_unknown_instances = meta_ignore_instances;
|
||||
}
|
||||
|
||||
for (key, value) in self.properties {
|
||||
let resolved_value = try_resolve_value(&snapshot.class_name, &key, &value)?;
|
||||
snapshot.properties.insert(key, resolved_value);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn locate(imfs: &Imfs, path: &Path) -> Result<Option<ExtraMetadata>, SnapshotError> {
|
||||
match imfs.get(&path.with_extension("meta.json")) {
|
||||
Some(ImfsItem::File(file)) => {
|
||||
let meta: ExtraMetadata = serde_json::from_slice(&file.contents)
|
||||
.map_err(|inner| SnapshotError::ExtraMetadataError {
|
||||
inner,
|
||||
path: file.path.to_path_buf(),
|
||||
})?;
|
||||
|
||||
Ok(Some(meta))
|
||||
}
|
||||
_ => Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_for_non_init(&self, path: &Path) -> Result<(), SnapshotError> {
|
||||
if self.class_name.is_some() {
|
||||
return Err(SnapshotError::MetadataClassNameNonInit {
|
||||
path: path.to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_for_model(&self, path: &Path) -> Result<(), SnapshotError> {
|
||||
if self.class_name.is_some() {
|
||||
return Err(SnapshotError::InvalidMetadataModelField {
|
||||
field_name: "className".to_owned(),
|
||||
path: path.to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
if !self.properties.is_empty() {
|
||||
return Err(SnapshotError::InvalidMetadataModelField {
|
||||
field_name: "properties".to_owned(),
|
||||
path: path.to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_imfs_file<'source>(
|
||||
context: &SnapshotContext,
|
||||
imfs: &'source Imfs,
|
||||
file: &'source ImfsFile,
|
||||
instance_name: Option<Cow<'source, str>>,
|
||||
) -> SnapshotResult<'source> {
|
||||
@@ -243,19 +470,46 @@ fn snapshot_imfs_file<'source>(
|
||||
.map(|v| v.to_str().expect("Could not convert extension to UTF-8"));
|
||||
|
||||
let mut maybe_snapshot = match extension {
|
||||
Some("lua") => snapshot_lua_file(file)?,
|
||||
Some("csv") => snapshot_csv_file(file)?,
|
||||
Some("txt") => snapshot_txt_file(file)?,
|
||||
Some("rbxmx") => snapshot_xml_model_file(file)?,
|
||||
Some("rbxm") => snapshot_binary_model_file(file)?,
|
||||
Some(_) | None => return Ok(None),
|
||||
Some("lua") => snapshot_lua_file(file, imfs)?,
|
||||
Some("csv") => snapshot_csv_file(file, imfs)?,
|
||||
Some("txt") => snapshot_txt_file(file, imfs)?,
|
||||
Some("rbxmx") => snapshot_xml_model_file(file, imfs)?,
|
||||
Some("rbxm") => snapshot_binary_model_file(file, imfs)?,
|
||||
Some("json") => {
|
||||
let file_stem = file.path
|
||||
.file_stem().expect("Could not extract file stem")
|
||||
.to_str().expect("Could not convert path to UTF-8");
|
||||
|
||||
if file_stem.ends_with(".model") {
|
||||
snapshot_json_model_file(file)?
|
||||
} else {
|
||||
None
|
||||
}
|
||||
},
|
||||
Some(_) | None => None,
|
||||
};
|
||||
|
||||
if let Some(snapshot) = maybe_snapshot.as_mut() {
|
||||
if let Some(mut snapshot) = maybe_snapshot.as_mut() {
|
||||
// Carefully preserve name from project manifest if present.
|
||||
if let Some(snapshot_name) = instance_name {
|
||||
snapshot.name = snapshot_name;
|
||||
}
|
||||
} else {
|
||||
info!("File generated no snapshot: {}", file.path.display());
|
||||
}
|
||||
|
||||
if let Some(snapshot) = maybe_snapshot.as_ref() {
|
||||
if let Some(plugin_context) = &context.plugin_context {
|
||||
for plugin in &plugin_context.plugins {
|
||||
let owned_snapshot = snapshot.get_owned();
|
||||
let registry_key = &plugin.callback;
|
||||
|
||||
plugin_context.lua.context(move |context| {
|
||||
let callback: rlua::Function = context.registry_value(registry_key).unwrap();
|
||||
callback.call::<_, ()>(LuaRbxSnapshot(owned_snapshot)).unwrap();
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(maybe_snapshot)
|
||||
@@ -263,6 +517,7 @@ fn snapshot_imfs_file<'source>(
|
||||
|
||||
fn snapshot_lua_file<'source>(
|
||||
file: &'source ImfsFile,
|
||||
imfs: &'source Imfs,
|
||||
) -> SnapshotResult<'source> {
|
||||
let file_stem = file.path
|
||||
.file_stem().expect("Could not extract file stem")
|
||||
@@ -282,7 +537,7 @@ fn snapshot_lua_file<'source>(
|
||||
path: file.path.to_path_buf(),
|
||||
})?;
|
||||
|
||||
Ok(Some(RbxSnapshotInstance {
|
||||
let mut snapshot = RbxSnapshotInstance {
|
||||
name: Cow::Borrowed(instance_name),
|
||||
class_name: Cow::Borrowed(class_name),
|
||||
properties: hashmap! {
|
||||
@@ -294,8 +549,16 @@ fn snapshot_lua_file<'source>(
|
||||
metadata: MetadataPerInstance {
|
||||
source_path: Some(file.path.to_path_buf()),
|
||||
ignore_unknown_instances: false,
|
||||
project_definition: None,
|
||||
},
|
||||
}))
|
||||
};
|
||||
|
||||
if let Some(meta) = ExtraMetadata::locate(&imfs, &file.path.with_file_name(instance_name))? {
|
||||
meta.validate_for_non_init(&file.path)?;
|
||||
meta.apply(&mut snapshot)?;
|
||||
}
|
||||
|
||||
Ok(Some(snapshot))
|
||||
}
|
||||
|
||||
fn match_trailing<'a>(input: &'a str, trailer: &str) -> Option<&'a str> {
|
||||
@@ -309,6 +572,7 @@ fn match_trailing<'a>(input: &'a str, trailer: &str) -> Option<&'a str> {
|
||||
|
||||
fn snapshot_txt_file<'source>(
|
||||
file: &'source ImfsFile,
|
||||
imfs: &'source Imfs,
|
||||
) -> SnapshotResult<'source> {
|
||||
let instance_name = file.path
|
||||
.file_stem().expect("Could not extract file stem")
|
||||
@@ -320,7 +584,7 @@ fn snapshot_txt_file<'source>(
|
||||
path: file.path.to_path_buf(),
|
||||
})?;
|
||||
|
||||
Ok(Some(RbxSnapshotInstance {
|
||||
let mut snapshot = RbxSnapshotInstance {
|
||||
name: Cow::Borrowed(instance_name),
|
||||
class_name: Cow::Borrowed("StringValue"),
|
||||
properties: hashmap! {
|
||||
@@ -332,28 +596,108 @@ fn snapshot_txt_file<'source>(
|
||||
metadata: MetadataPerInstance {
|
||||
source_path: Some(file.path.to_path_buf()),
|
||||
ignore_unknown_instances: false,
|
||||
project_definition: None,
|
||||
},
|
||||
}))
|
||||
};
|
||||
|
||||
if let Some(meta) = ExtraMetadata::locate(&imfs, &file.path)? {
|
||||
meta.validate_for_non_init(&file.path)?;
|
||||
meta.apply(&mut snapshot)?;
|
||||
}
|
||||
|
||||
Ok(Some(snapshot))
|
||||
}
|
||||
|
||||
fn snapshot_csv_file<'source>(
|
||||
file: &'source ImfsFile,
|
||||
imfs: &'source Imfs,
|
||||
) -> SnapshotResult<'source> {
|
||||
/// Struct that holds any valid row from a Roblox CSV translation table.
|
||||
///
|
||||
/// We manually deserialize into this table from CSV, but let JSON handle
|
||||
/// serializing.
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LocalizationEntry<'a> {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
key: Option<&'a str>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
context: Option<&'a str>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
example: Option<&'a str>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
source: Option<&'a str>,
|
||||
|
||||
values: HashMap<&'a str, &'a str>,
|
||||
}
|
||||
|
||||
let instance_name = file.path
|
||||
.file_stem().expect("Could not extract file stem")
|
||||
.to_str().expect("Could not convert path to UTF-8");
|
||||
|
||||
let entries: Vec<LocalizationEntryJson> = csv::Reader::from_reader(file.contents.as_slice())
|
||||
.deserialize()
|
||||
// TODO: Propagate error upward instead of panicking
|
||||
.map(|result| result.expect("Malformed localization table found!"))
|
||||
.map(LocalizationEntryCsv::to_json)
|
||||
.collect();
|
||||
// Normally, we'd be able to let the csv crate construct our struct for us.
|
||||
//
|
||||
// However, because of a limitation with Serde's 'flatten' feature, it's not
|
||||
// possible presently to losslessly collect extra string values while using
|
||||
// csv+Serde.
|
||||
//
|
||||
// https://github.com/BurntSushi/rust-csv/issues/151
|
||||
let mut reader = csv::Reader::from_reader(file.contents.as_slice());
|
||||
|
||||
let headers = reader.headers()
|
||||
.map_err(|inner| SnapshotError::CsvDecodeError {
|
||||
inner,
|
||||
path: file.path.to_path_buf(),
|
||||
})?
|
||||
.clone();
|
||||
|
||||
let mut records = Vec::new();
|
||||
|
||||
for record in reader.into_records() {
|
||||
let record = record
|
||||
.map_err(|inner| SnapshotError::CsvDecodeError {
|
||||
inner,
|
||||
path: file.path.to_path_buf(),
|
||||
})?;
|
||||
|
||||
records.push(record);
|
||||
}
|
||||
|
||||
let mut entries = Vec::new();
|
||||
|
||||
for record in &records {
|
||||
let mut entry = LocalizationEntry::default();
|
||||
|
||||
for (header, value) in headers.iter().zip(record.into_iter()) {
|
||||
if header.is_empty() || value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match header {
|
||||
"Key" => entry.key = Some(value),
|
||||
"Source" => entry.source = Some(value),
|
||||
"Context" => entry.context = Some(value),
|
||||
"Example" => entry.example = Some(value),
|
||||
_ => {
|
||||
entry.values.insert(header, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if entry.key.is_none() && entry.source.is_none() {
|
||||
continue;
|
||||
}
|
||||
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
let table_contents = serde_json::to_string(&entries)
|
||||
.expect("Could not encode JSON for localization table");
|
||||
|
||||
Ok(Some(RbxSnapshotInstance {
|
||||
let mut snapshot = RbxSnapshotInstance {
|
||||
name: Cow::Borrowed(instance_name),
|
||||
class_name: Cow::Borrowed("LocalizationTable"),
|
||||
properties: hashmap! {
|
||||
@@ -365,64 +709,95 @@ fn snapshot_csv_file<'source>(
|
||||
metadata: MetadataPerInstance {
|
||||
source_path: Some(file.path.to_path_buf()),
|
||||
ignore_unknown_instances: false,
|
||||
project_definition: None,
|
||||
},
|
||||
}))
|
||||
};
|
||||
|
||||
if let Some(meta) = ExtraMetadata::locate(&imfs, &file.path)? {
|
||||
meta.validate_for_non_init(&file.path)?;
|
||||
meta.apply(&mut snapshot)?;
|
||||
}
|
||||
|
||||
Ok(Some(snapshot))
|
||||
}
|
||||
|
||||
fn snapshot_json_model_file<'source>(
|
||||
file: &'source ImfsFile,
|
||||
) -> SnapshotResult<'source> {
|
||||
let contents = str::from_utf8(&file.contents)
|
||||
.map_err(|inner| SnapshotError::Utf8Error {
|
||||
inner,
|
||||
path: file.path.to_owned(),
|
||||
})?;
|
||||
|
||||
let json_instance: JsonModelInstance = serde_json::from_str(contents)
|
||||
.map_err(|inner| SnapshotError::JsonModelDecodeError {
|
||||
inner,
|
||||
path: file.path.to_owned(),
|
||||
})?;
|
||||
|
||||
let mut snapshot = json_instance.into_snapshot()?;
|
||||
snapshot.metadata.source_path = Some(file.path.to_owned());
|
||||
|
||||
Ok(Some(snapshot))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "PascalCase")]
|
||||
struct LocalizationEntryCsv {
|
||||
key: String,
|
||||
context: String,
|
||||
example: String,
|
||||
source: String,
|
||||
#[serde(flatten)]
|
||||
values: HashMap<String, String>,
|
||||
struct JsonModelInstance {
|
||||
name: String,
|
||||
class_name: String,
|
||||
|
||||
#[serde(default = "Vec::new", skip_serializing_if = "Vec::is_empty")]
|
||||
children: Vec<JsonModelInstance>,
|
||||
|
||||
#[serde(default = "HashMap::new", skip_serializing_if = "HashMap::is_empty")]
|
||||
properties: HashMap<String, UnresolvedRbxValue>,
|
||||
}
|
||||
|
||||
impl LocalizationEntryCsv {
|
||||
fn to_json(self) -> LocalizationEntryJson {
|
||||
LocalizationEntryJson {
|
||||
key: self.key,
|
||||
context: self.context,
|
||||
example: self.example,
|
||||
source: self.source,
|
||||
values: self.values,
|
||||
impl JsonModelInstance {
|
||||
fn into_snapshot(self) -> Result<RbxSnapshotInstance<'static>, SnapshotError> {
|
||||
let mut children = Vec::with_capacity(self.children.len());
|
||||
|
||||
for child in self.children {
|
||||
children.push(child.into_snapshot()?);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct LocalizationEntryJson {
|
||||
key: String,
|
||||
context: String,
|
||||
example: String,
|
||||
source: String,
|
||||
values: HashMap<String, String>,
|
||||
let mut properties = HashMap::with_capacity(self.properties.len());
|
||||
|
||||
for (key, value) in self.properties {
|
||||
let resolved_value = try_resolve_value(&self.class_name, &key, &value)?;
|
||||
properties.insert(key, resolved_value);
|
||||
}
|
||||
|
||||
Ok(RbxSnapshotInstance {
|
||||
name: Cow::Owned(self.name),
|
||||
class_name: Cow::Owned(self.class_name),
|
||||
properties,
|
||||
children,
|
||||
metadata: Default::default(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot_xml_model_file<'source>(
|
||||
file: &'source ImfsFile,
|
||||
imfs: &'source Imfs,
|
||||
) -> SnapshotResult<'source> {
|
||||
let instance_name = file.path
|
||||
.file_stem().expect("Could not extract file stem")
|
||||
.to_str().expect("Could not convert path to UTF-8");
|
||||
|
||||
let mut temp_tree = RbxTree::new(RbxInstanceProperties {
|
||||
name: "Temp".to_owned(),
|
||||
class_name: "Folder".to_owned(),
|
||||
properties: HashMap::new(),
|
||||
});
|
||||
let options = rbx_xml::DecodeOptions::new()
|
||||
.property_behavior(rbx_xml::DecodePropertyBehavior::ReadUnknown);
|
||||
|
||||
let root_id = temp_tree.get_root_id();
|
||||
rbx_xml::decode(&mut temp_tree, root_id, file.contents.as_slice())
|
||||
let temp_tree = rbx_xml::from_reader(file.contents.as_slice(), options)
|
||||
.map_err(|inner| SnapshotError::XmlModelDecodeError {
|
||||
inner,
|
||||
path: file.path.clone(),
|
||||
})?;
|
||||
|
||||
let root_instance = temp_tree.get_instance(root_id).unwrap();
|
||||
let root_instance = temp_tree.get_instance(temp_tree.get_root_id()).unwrap();
|
||||
let children = root_instance.get_children_ids();
|
||||
|
||||
match children.len() {
|
||||
@@ -430,6 +805,13 @@ fn snapshot_xml_model_file<'source>(
|
||||
1 => {
|
||||
let mut snapshot = snapshot_from_tree(&temp_tree, children[0]).unwrap();
|
||||
snapshot.name = Cow::Borrowed(instance_name);
|
||||
snapshot.metadata.source_path = Some(file.path.clone());
|
||||
|
||||
if let Some(meta) = ExtraMetadata::locate(&imfs, &file.path)? {
|
||||
meta.validate_for_model(&file.path)?;
|
||||
meta.apply(&mut snapshot)?;
|
||||
}
|
||||
|
||||
Ok(Some(snapshot))
|
||||
},
|
||||
_ => panic!("Rojo doesn't have support for model files with multiple roots yet"),
|
||||
@@ -438,6 +820,7 @@ fn snapshot_xml_model_file<'source>(
|
||||
|
||||
fn snapshot_binary_model_file<'source>(
|
||||
file: &'source ImfsFile,
|
||||
imfs: &'source Imfs,
|
||||
) -> SnapshotResult<'source> {
|
||||
let instance_name = file.path
|
||||
.file_stem().expect("Could not extract file stem")
|
||||
@@ -464,6 +847,13 @@ fn snapshot_binary_model_file<'source>(
|
||||
1 => {
|
||||
let mut snapshot = snapshot_from_tree(&temp_tree, children[0]).unwrap();
|
||||
snapshot.name = Cow::Borrowed(instance_name);
|
||||
snapshot.metadata.source_path = Some(file.path.clone());
|
||||
|
||||
if let Some(meta) = ExtraMetadata::locate(&imfs, &file.path)? {
|
||||
meta.validate_for_model(&file.path)?;
|
||||
meta.apply(&mut snapshot)?;
|
||||
}
|
||||
|
||||
Ok(Some(snapshot))
|
||||
},
|
||||
_ => panic!("Rojo doesn't have support for model files with multiple roots yet"),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use serde_derive::{Serialize, Deserialize};
|
||||
use serde::{Serialize, Deserialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
//! Defines the snapshot subsystem of Rojo, which defines a lightweight instance
|
||||
//! representation (`RbxSnapshotInstance`) and a system to incrementally update
|
||||
//! an `RbxTree` based on snapshots.
|
||||
|
||||
use std::{
|
||||
str,
|
||||
borrow::Cow,
|
||||
cmp::Ordering,
|
||||
collections::{HashMap, HashSet},
|
||||
fmt,
|
||||
str,
|
||||
};
|
||||
|
||||
use rbx_tree::{RbxTree, RbxId, RbxInstanceProperties, RbxValue};
|
||||
use serde_derive::{Serialize, Deserialize};
|
||||
use rbx_dom_weak::{RbxTree, RbxId, RbxInstanceProperties, RbxValue};
|
||||
use serde::{Serialize, Deserialize};
|
||||
|
||||
use crate::{
|
||||
path_map::PathMap,
|
||||
rbx_session::{MetadataPerPath, MetadataPerInstance},
|
||||
rbx_session::MetadataPerInstance,
|
||||
};
|
||||
|
||||
/// Contains all of the IDs that were modified when the snapshot reconciler
|
||||
/// applied an update.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct InstanceChanges {
|
||||
pub added: HashSet<RbxId>,
|
||||
@@ -55,7 +62,9 @@ impl InstanceChanges {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
/// A lightweight, hierarchical representation of an instance that can be
|
||||
/// applied to the tree.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RbxSnapshotInstance<'a> {
|
||||
pub name: Cow<'a, str>,
|
||||
pub class_name: Cow<'a, str>,
|
||||
@@ -64,6 +73,34 @@ pub struct RbxSnapshotInstance<'a> {
|
||||
pub metadata: MetadataPerInstance,
|
||||
}
|
||||
|
||||
impl<'a> RbxSnapshotInstance<'a> {
|
||||
pub fn get_owned(&'a self) -> RbxSnapshotInstance<'static> {
|
||||
let children: Vec<RbxSnapshotInstance<'static>> = self.children.iter()
|
||||
.map(RbxSnapshotInstance::get_owned)
|
||||
.collect();
|
||||
|
||||
RbxSnapshotInstance {
|
||||
name: Cow::Owned(self.name.clone().into_owned()),
|
||||
class_name: Cow::Owned(self.class_name.clone().into_owned()),
|
||||
properties: self.properties.clone(),
|
||||
children,
|
||||
metadata: self.metadata.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<'a> PartialOrd for RbxSnapshotInstance<'a> {
|
||||
fn partial_cmp(&self, other: &RbxSnapshotInstance) -> Option<Ordering> {
|
||||
Some(self.name.cmp(&other.name)
|
||||
.then(self.class_name.cmp(&other.class_name)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates an `RbxSnapshotInstance` from an existing `RbxTree` and an ID to
|
||||
/// use as the root of the snapshot.
|
||||
///
|
||||
/// This is used to transform instances created by rbx_xml and rbx_binary into
|
||||
/// snapshots that can be applied to the tree to reduce instance churn.
|
||||
pub fn snapshot_from_tree(tree: &RbxTree, id: RbxId) -> Option<RbxSnapshotInstance<'static>> {
|
||||
let instance = tree.get_instance(id)?;
|
||||
|
||||
@@ -80,81 +117,95 @@ pub fn snapshot_from_tree(tree: &RbxTree, id: RbxId) -> Option<RbxSnapshotInstan
|
||||
metadata: MetadataPerInstance {
|
||||
source_path: None,
|
||||
ignore_unknown_instances: false,
|
||||
project_definition: None,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Constructs a new `RbxTree` out of a snapshot and places to attach metadata.
|
||||
pub fn reify_root(
|
||||
snapshot: &RbxSnapshotInstance,
|
||||
metadata_per_path: &mut PathMap<MetadataPerPath>,
|
||||
instance_metadata_map: &mut HashMap<RbxId, MetadataPerInstance>,
|
||||
instance_per_path: &mut PathMap<HashSet<RbxId>>,
|
||||
metadata_per_instance: &mut HashMap<RbxId, MetadataPerInstance>,
|
||||
changes: &mut InstanceChanges,
|
||||
) -> RbxTree {
|
||||
let instance = reify_core(snapshot);
|
||||
let mut tree = RbxTree::new(instance);
|
||||
let root_id = tree.get_root_id();
|
||||
let id = tree.get_root_id();
|
||||
|
||||
if let Some(source_path) = &snapshot.metadata.source_path {
|
||||
let path_meta = metadata_per_path.entry(source_path.to_owned()).or_default();
|
||||
path_meta.instance_id = Some(root_id);
|
||||
}
|
||||
reify_metadata(snapshot, id, instance_per_path, metadata_per_instance);
|
||||
|
||||
instance_metadata_map.insert(root_id, snapshot.metadata.clone());
|
||||
|
||||
changes.added.insert(root_id);
|
||||
changes.added.insert(id);
|
||||
|
||||
for child in &snapshot.children {
|
||||
reify_subtree(child, &mut tree, root_id, metadata_per_path, instance_metadata_map, changes);
|
||||
reify_subtree(child, &mut tree, id, instance_per_path, metadata_per_instance, changes);
|
||||
}
|
||||
|
||||
tree
|
||||
}
|
||||
|
||||
/// Adds instances to a portion of the given `RbxTree`, used for when new
|
||||
/// instances are created.
|
||||
pub fn reify_subtree(
|
||||
snapshot: &RbxSnapshotInstance,
|
||||
tree: &mut RbxTree,
|
||||
parent_id: RbxId,
|
||||
metadata_per_path: &mut PathMap<MetadataPerPath>,
|
||||
instance_metadata_map: &mut HashMap<RbxId, MetadataPerInstance>,
|
||||
instance_per_path: &mut PathMap<HashSet<RbxId>>,
|
||||
metadata_per_instance: &mut HashMap<RbxId, MetadataPerInstance>,
|
||||
changes: &mut InstanceChanges,
|
||||
) {
|
||||
) -> RbxId {
|
||||
let instance = reify_core(snapshot);
|
||||
let id = tree.insert_instance(instance, parent_id);
|
||||
|
||||
if let Some(source_path) = &snapshot.metadata.source_path {
|
||||
let path_meta = metadata_per_path.entry(source_path.clone()).or_default();
|
||||
path_meta.instance_id = Some(id);
|
||||
}
|
||||
|
||||
instance_metadata_map.insert(id, snapshot.metadata.clone());
|
||||
reify_metadata(snapshot, id, instance_per_path, metadata_per_instance);
|
||||
|
||||
changes.added.insert(id);
|
||||
|
||||
for child in &snapshot.children {
|
||||
reify_subtree(child, tree, id, metadata_per_path, instance_metadata_map, changes);
|
||||
reify_subtree(child, tree, id, instance_per_path, metadata_per_instance, changes);
|
||||
}
|
||||
|
||||
id
|
||||
}
|
||||
|
||||
fn reify_metadata(
|
||||
snapshot: &RbxSnapshotInstance,
|
||||
instance_id: RbxId,
|
||||
instance_per_path: &mut PathMap<HashSet<RbxId>>,
|
||||
metadata_per_instance: &mut HashMap<RbxId, MetadataPerInstance>,
|
||||
) {
|
||||
if let Some(source_path) = &snapshot.metadata.source_path {
|
||||
let path_metadata = match instance_per_path.get_mut(&source_path) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
instance_per_path.insert(source_path.clone(), Default::default());
|
||||
instance_per_path.get_mut(&source_path).unwrap()
|
||||
},
|
||||
};
|
||||
|
||||
path_metadata.insert(instance_id);
|
||||
}
|
||||
|
||||
metadata_per_instance.insert(instance_id, snapshot.metadata.clone());
|
||||
}
|
||||
|
||||
/// Updates existing instances in an existing `RbxTree`, potentially adding,
|
||||
/// updating, or removing children and properties.
|
||||
pub fn reconcile_subtree(
|
||||
tree: &mut RbxTree,
|
||||
id: RbxId,
|
||||
snapshot: &RbxSnapshotInstance,
|
||||
metadata_per_path: &mut PathMap<MetadataPerPath>,
|
||||
instance_metadata_map: &mut HashMap<RbxId, MetadataPerInstance>,
|
||||
instance_per_path: &mut PathMap<HashSet<RbxId>>,
|
||||
metadata_per_instance: &mut HashMap<RbxId, MetadataPerInstance>,
|
||||
changes: &mut InstanceChanges,
|
||||
) {
|
||||
if let Some(source_path) = &snapshot.metadata.source_path {
|
||||
let path_meta = metadata_per_path.entry(source_path.to_owned()).or_default();
|
||||
path_meta.instance_id = Some(id);
|
||||
}
|
||||
|
||||
instance_metadata_map.insert(id, snapshot.metadata.clone());
|
||||
reify_metadata(snapshot, id, instance_per_path, metadata_per_instance);
|
||||
|
||||
if reconcile_instance_properties(tree.get_instance_mut(id).unwrap(), snapshot) {
|
||||
changes.updated.insert(id);
|
||||
}
|
||||
|
||||
reconcile_instance_children(tree, id, snapshot, metadata_per_path, instance_metadata_map, changes);
|
||||
reconcile_instance_children(tree, id, snapshot, instance_per_path, metadata_per_instance, changes);
|
||||
}
|
||||
|
||||
fn reify_core(snapshot: &RbxSnapshotInstance) -> RbxInstanceProperties {
|
||||
@@ -173,6 +224,9 @@ fn reify_core(snapshot: &RbxSnapshotInstance) -> RbxInstanceProperties {
|
||||
instance
|
||||
}
|
||||
|
||||
/// Updates the given instance to match the properties defined on the snapshot.
|
||||
///
|
||||
/// Returns whether any changes were applied.
|
||||
fn reconcile_instance_properties(instance: &mut RbxInstanceProperties, snapshot: &RbxSnapshotInstance) -> bool {
|
||||
let mut has_diffs = false;
|
||||
|
||||
@@ -230,20 +284,31 @@ fn reconcile_instance_properties(instance: &mut RbxInstanceProperties, snapshot:
|
||||
has_diffs
|
||||
}
|
||||
|
||||
/// Updates the children of the instance in the `RbxTree` to match the children
|
||||
/// of the `RbxSnapshotInstance`. Order will be updated to match.
|
||||
fn reconcile_instance_children(
|
||||
tree: &mut RbxTree,
|
||||
id: RbxId,
|
||||
snapshot: &RbxSnapshotInstance,
|
||||
metadata_per_path: &mut PathMap<MetadataPerPath>,
|
||||
instance_metadata_map: &mut HashMap<RbxId, MetadataPerInstance>,
|
||||
instance_per_path: &mut PathMap<HashSet<RbxId>>,
|
||||
metadata_per_instance: &mut HashMap<RbxId, MetadataPerInstance>,
|
||||
changes: &mut InstanceChanges,
|
||||
) {
|
||||
let mut visited_snapshot_indices = HashSet::new();
|
||||
|
||||
let mut children_to_update: Vec<(RbxId, &RbxSnapshotInstance)> = Vec::new();
|
||||
let mut children_to_add: Vec<&RbxSnapshotInstance> = Vec::new();
|
||||
// These lists are kept so that we can apply all the changes we figure out
|
||||
let mut children_to_maybe_update: Vec<(RbxId, &RbxSnapshotInstance)> = Vec::new();
|
||||
let mut children_to_add: Vec<(usize, &RbxSnapshotInstance)> = Vec::new();
|
||||
let mut children_to_remove: Vec<RbxId> = Vec::new();
|
||||
|
||||
// This map is used once we're done mutating children to sort them according
|
||||
// to the order specified in the snapshot. Without it, a snapshot with a new
|
||||
// child prepended will cause the RbxTree instance to have out-of-order
|
||||
// children and would make Rojo non-deterministic.
|
||||
let mut ids_to_snapshot_indices = HashMap::new();
|
||||
|
||||
// Since we have to enumerate the children of both the RbxTree instance and
|
||||
// our snapshot, we keep a set of the snapshot children we've seen.
|
||||
let mut visited_snapshot_indices = vec![false; snapshot.children.len()];
|
||||
|
||||
let children_ids = tree.get_instance(id).unwrap().get_children_ids();
|
||||
|
||||
// Find all instances that were removed or updated, which we derive by
|
||||
@@ -254,7 +319,7 @@ fn reconcile_instance_children(
|
||||
// Locate a matching snapshot for this instance
|
||||
let mut matching_snapshot = None;
|
||||
for (snapshot_index, child_snapshot) in snapshot.children.iter().enumerate() {
|
||||
if visited_snapshot_indices.contains(&snapshot_index) {
|
||||
if visited_snapshot_indices[snapshot_index] {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -262,7 +327,8 @@ fn reconcile_instance_children(
|
||||
// similar. This heuristic is similar to React's reconciliation
|
||||
// strategy.
|
||||
if child_snapshot.name == child_instance.name {
|
||||
visited_snapshot_indices.insert(snapshot_index);
|
||||
ids_to_snapshot_indices.insert(child_id, snapshot_index);
|
||||
visited_snapshot_indices[snapshot_index] = true;
|
||||
matching_snapshot = Some(child_snapshot);
|
||||
break;
|
||||
}
|
||||
@@ -270,36 +336,44 @@ fn reconcile_instance_children(
|
||||
|
||||
match matching_snapshot {
|
||||
Some(child_snapshot) => {
|
||||
children_to_update.push((child_instance.get_id(), child_snapshot));
|
||||
},
|
||||
children_to_maybe_update.push((child_instance.get_id(), child_snapshot));
|
||||
}
|
||||
None => {
|
||||
children_to_remove.push(child_instance.get_id());
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find all instancs that were added, which is just the snapshots we didn't
|
||||
// match up to existing instances above.
|
||||
for (snapshot_index, child_snapshot) in snapshot.children.iter().enumerate() {
|
||||
if !visited_snapshot_indices.contains(&snapshot_index) {
|
||||
children_to_add.push(child_snapshot);
|
||||
if !visited_snapshot_indices[snapshot_index] {
|
||||
children_to_add.push((snapshot_index, child_snapshot));
|
||||
}
|
||||
}
|
||||
|
||||
for child_snapshot in &children_to_add {
|
||||
reify_subtree(child_snapshot, tree, id, metadata_per_path, instance_metadata_map, changes);
|
||||
}
|
||||
|
||||
// Apply all of our removals we gathered from our diff
|
||||
for child_id in &children_to_remove {
|
||||
if let Some(subtree) = tree.remove_instance(*child_id) {
|
||||
for id in subtree.iter_all_ids() {
|
||||
instance_metadata_map.remove(&id);
|
||||
metadata_per_instance.remove(&id);
|
||||
changes.removed.insert(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (child_id, child_snapshot) in &children_to_update {
|
||||
reconcile_subtree(tree, *child_id, child_snapshot, metadata_per_path, instance_metadata_map, changes);
|
||||
// Apply all of our children additions
|
||||
for (snapshot_index, child_snapshot) in &children_to_add {
|
||||
let id = reify_subtree(child_snapshot, tree, id, instance_per_path, metadata_per_instance, changes);
|
||||
ids_to_snapshot_indices.insert(id, *snapshot_index);
|
||||
}
|
||||
|
||||
// Apply any updates that might have updates
|
||||
for (child_id, child_snapshot) in &children_to_maybe_update {
|
||||
reconcile_subtree(tree, *child_id, child_snapshot, instance_per_path, metadata_per_instance, changes);
|
||||
}
|
||||
|
||||
// Apply the sort mapping defined by ids_to_snapshot_indices above
|
||||
let instance = tree.get_instance_mut(id).unwrap();
|
||||
instance.sort_children_unstable_by_key(|id| ids_to_snapshot_indices.get(&id).unwrap());
|
||||
}
|
||||
@@ -1,16 +1,19 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
fmt,
|
||||
io::Write,
|
||||
path::Path,
|
||||
process::{Command, Stdio},
|
||||
};
|
||||
|
||||
use rbx_tree::RbxId;
|
||||
use log::warn;
|
||||
use rbx_dom_weak::{RbxTree, RbxId};
|
||||
|
||||
use crate::{
|
||||
imfs::{Imfs, ImfsItem},
|
||||
rbx_session::RbxSession,
|
||||
web::InstanceMetadata,
|
||||
web::api::PublicInstanceMetadata,
|
||||
rbx_session::MetadataPerInstance,
|
||||
};
|
||||
|
||||
static GRAPHVIZ_HEADER: &str = r#"
|
||||
@@ -27,13 +30,21 @@ digraph RojoTree {
|
||||
"#;
|
||||
|
||||
/// Compiles DOT source to SVG by invoking dot on the command line.
|
||||
pub fn graphviz_to_svg(source: &str) -> String {
|
||||
let mut child = Command::new("dot")
|
||||
pub fn graphviz_to_svg(source: &str) -> Option<String> {
|
||||
let command = Command::new("dot")
|
||||
.arg("-Tsvg")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.spawn()
|
||||
.expect("Failed to spawn GraphViz process -- make sure it's installed in order to use /api/visualize");
|
||||
.spawn();
|
||||
|
||||
let mut child = match command {
|
||||
Ok(child) => child,
|
||||
Err(_) => {
|
||||
warn!("Failed to spawn GraphViz process to visualize current state.");
|
||||
warn!("If you want pretty graphs, install GraphViz and make sure 'dot' is on your PATH!");
|
||||
return None;
|
||||
},
|
||||
};
|
||||
|
||||
{
|
||||
let stdin = child.stdin.as_mut().expect("Failed to open stdin");
|
||||
@@ -41,7 +52,22 @@ pub fn graphviz_to_svg(source: &str) -> String {
|
||||
}
|
||||
|
||||
let output = child.wait_with_output().expect("Failed to read stdout");
|
||||
String::from_utf8(output.stdout).expect("Failed to parse stdout as UTF-8")
|
||||
Some(String::from_utf8(output.stdout).expect("Failed to parse stdout as UTF-8"))
|
||||
}
|
||||
|
||||
pub struct VisualizeRbxTree<'a, 'b> {
|
||||
pub tree: &'a RbxTree,
|
||||
pub metadata: &'b HashMap<RbxId, MetadataPerInstance>,
|
||||
}
|
||||
|
||||
impl<'a, 'b> fmt::Display for VisualizeRbxTree<'a, 'b> {
|
||||
fn fmt(&self, output: &mut fmt::Formatter) -> fmt::Result {
|
||||
writeln!(output, "{}", GRAPHVIZ_HEADER)?;
|
||||
|
||||
visualize_instance(&self.tree, self.tree.get_root_id(), &self.metadata, output)?;
|
||||
|
||||
writeln!(output, "}}")
|
||||
}
|
||||
}
|
||||
|
||||
/// A Display wrapper struct to visualize an RbxSession as SVG.
|
||||
@@ -49,37 +75,39 @@ pub struct VisualizeRbxSession<'a>(pub &'a RbxSession);
|
||||
|
||||
impl<'a> fmt::Display for VisualizeRbxSession<'a> {
|
||||
fn fmt(&self, output: &mut fmt::Formatter) -> fmt::Result {
|
||||
writeln!(output, "{}", GRAPHVIZ_HEADER)?;
|
||||
|
||||
visualize_rbx_node(self.0, self.0.get_tree().get_root_id(), output)?;
|
||||
|
||||
writeln!(output, "}}")?;
|
||||
|
||||
Ok(())
|
||||
writeln!(output, "{}", VisualizeRbxTree {
|
||||
tree: self.0.get_tree(),
|
||||
metadata: self.0.get_all_instance_metadata(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn visualize_rbx_node(session: &RbxSession, id: RbxId, output: &mut fmt::Formatter) -> fmt::Result {
|
||||
let node = session.get_tree().get_instance(id).unwrap();
|
||||
fn visualize_instance(
|
||||
tree: &RbxTree,
|
||||
id: RbxId,
|
||||
metadata: &HashMap<RbxId, MetadataPerInstance>,
|
||||
output: &mut fmt::Formatter,
|
||||
) -> fmt::Result {
|
||||
let instance = tree.get_instance(id).unwrap();
|
||||
|
||||
let mut node_label = format!("{}|{}|{}", node.name, node.class_name, id);
|
||||
let mut instance_label = format!("{}|{}|{}", instance.name, instance.class_name, id);
|
||||
|
||||
if let Some(session_metadata) = session.get_instance_metadata(id) {
|
||||
let metadata = InstanceMetadata::from_session_metadata(session_metadata);
|
||||
node_label.push('|');
|
||||
node_label.push_str(&serde_json::to_string(&metadata).unwrap());
|
||||
if let Some(session_metadata) = metadata.get(&id) {
|
||||
let metadata = PublicInstanceMetadata::from_session_metadata(session_metadata);
|
||||
instance_label.push('|');
|
||||
instance_label.push_str(&serde_json::to_string(&metadata).unwrap());
|
||||
}
|
||||
|
||||
node_label = node_label
|
||||
instance_label = instance_label
|
||||
.replace("\"", """)
|
||||
.replace("{", "\\{")
|
||||
.replace("}", "\\}");
|
||||
|
||||
writeln!(output, " \"{}\" [label=\"{}\"]", id, node_label)?;
|
||||
writeln!(output, " \"{}\" [label=\"{}\"]", id, instance_label)?;
|
||||
|
||||
for &child_id in node.get_children_ids() {
|
||||
for &child_id in instance.get_children_ids() {
|
||||
writeln!(output, " \"{}\" -> \"{}\"", id, child_id)?;
|
||||
visualize_rbx_node(session, child_id, output)?;
|
||||
visualize_instance(tree, child_id, metadata, output)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||