Compare commits

..

54 Commits

Author SHA1 Message Date
Lucien Greathouse
3b433e53be Memofs v0.1.1 2020-03-18 18:35:44 -07:00
Lucien Greathouse
28ddf40344 memofs: Update fs-err and use it more 2020-03-18 18:06:58 -07:00
Lucien Greathouse
c1286db9c1 Update Changelog 2020-03-18 16:26:41 -07:00
Lucien Greathouse
f13940262e Update CHANGELOG 2020-03-18 12:03:50 -07:00
Lucien Greathouse
9f0a6101b8 Add configurable color options 2020-03-18 12:03:07 -07:00
Lucien Greathouse
0b0fe01a7c Tidy up root repository files 2020-03-18 11:40:12 -07:00
Lucien Greathouse
85e098d5c8 Update README 2020-03-18 11:36:50 -07:00
Lucien Greathouse
e8d1faf4e2 Update changelog 2020-03-18 10:43:42 -07:00
Lucien Greathouse
2a46df1110 Expose two-way sync.
- Convert plugin DevSettings flag to settings panel feature
- Remove server feature, always enable write API
2020-03-18 10:39:40 -07:00
Lucien Greathouse
1601e6d26e Update changelog 2020-03-17 23:20:40 -07:00
Lucien Greathouse
0e4f6dea2b plugin: Add setting for opening scripts externally 2020-03-17 23:20:05 -07:00
Lucien Greathouse
a2356773dc Add checkbox and fill out settings panel 2020-03-17 23:14:32 -07:00
Lucien Greathouse
4a4da4737d Fix plugin settings persistent 2020-03-17 23:03:59 -07:00
Lucien Greathouse
2cefd1bf2e plugin: Add PluginSettings context item, render it in settings screen 2020-03-17 23:03:01 -07:00
Lucien Greathouse
c5ce15fe34 plugin: Add dummy settings panel 2020-03-17 22:38:53 -07:00
Lucien Greathouse
76dea568c9 Update changelog 2020-03-17 22:30:00 -07:00
Lucien Greathouse
8e81140eff Increase verbosity of logging 2020-03-17 22:29:23 -07:00
Lucien Greathouse
d58e1f0792 Add logging when running rojo build 2020-03-17 22:28:38 -07:00
Lucien Greathouse
830c242751 plugin: Stop using codename in dev mode 2020-03-17 22:25:04 -07:00
Lucien Greathouse
91d45afd0f Add plugin feature 'UnstableOpenScriptsExternally' 2020-03-17 18:13:52 -07:00
Lucien Greathouse
102c77b23e Implement /api/open/{id} to open a script by ID in your default editor 2020-03-17 17:50:54 -07:00
Lucien Greathouse
aa4039a2e7 bye snafu 2020-03-16 23:37:00 -07:00
Lucien Greathouse
c065ded440 Tidy up SnapshotError a lot 2020-03-16 21:35:46 -07:00
Lucien Greathouse
f69096dadb Use thiserror and anyhow for command-level error types 2020-03-16 21:13:38 -07:00
Lucien Greathouse
363f95ba14 memofs: Use fs_err instead of std::fs when possible 2020-03-16 20:35:58 -07:00
Lucien Greathouse
dcc15e8911 Refactor upload to use ServeSession and drop common_setup 2020-03-16 20:20:12 -07:00
Lucien Greathouse
bd13047b58 Release 0.6.0-alpha.3 2020-03-13 23:50:54 -07:00
Lucien Greathouse
1a83789c01 Update Changelog 2020-03-13 20:50:03 -07:00
Lucien Greathouse
1cbe272e19 Fix malformed meta files causing panics.
Fixes #280.
2020-03-13 20:38:06 -07:00
Lucien Greathouse
6de74b41b3 Update snapshot error type and handle serde_json errors in JSON models 2020-03-13 20:31:31 -07:00
Lucien Greathouse
b0fc9ee507 Rename 'src/common' in default place template to 'src/shared' 2020-03-13 20:25:50 -07:00
Lucien Greathouse
a95ffe1d31 Add snapshot error handling to ChangeProcessor 2020-03-13 20:24:14 -07:00
Lucien Greathouse
4119a510f5 Fix broken error source tracking 2020-03-13 20:12:42 -07:00
Lucien Greathouse
cfa7f03815 Add help link to default model project 2020-03-13 20:11:08 -07:00
Lucien Greathouse
9b4c89820d Fix extra Git output in rojo init and update place template 2020-03-13 20:10:31 -07:00
Lucien Greathouse
fe874720aa Upgrade rojo init to create README.md and create Git repo 2020-03-13 20:00:48 -07:00
Lucien Greathouse
f7c0f33eb5 Add brand new rojo init command 2020-03-13 19:33:43 -07:00
Lucien Greathouse
c1bf9d9dfc Update duplicate env_logger dep 2020-03-12 19:39:27 -07:00
Lucien Greathouse
255bf439d3 Add 'rojo doc' command 2020-03-12 16:02:19 -07:00
Lucien Greathouse
2a31937b81 Use CARGO_PKG_REPOSITORY instead of hard-coded URL 2020-03-12 15:48:30 -07:00
Lucien Greathouse
eb8964e1d1 Improve panic messaging and process behavior.
- Replaced main() to use custom panic hook
- Updated log formatting
- Switched to panic=abort, since we don't need to unwind now.
- Process will now abort if any thread panics.
2020-03-12 15:42:56 -07:00
Lucien Greathouse
fe0ca280a1 Update disabled CSV middleware test 2020-03-10 23:52:55 -07:00
Lucien Greathouse
e8e3b7b985 plugin: Fix dark theme when resized to be wide 2020-03-10 20:34:12 -07:00
Lucien Greathouse
c437507442 Update changelogs 2020-03-10 18:15:06 -07:00
Lucien Greathouse
ca151b434e Update memofs dependency 2020-03-10 18:13:34 -07:00
Lucien Greathouse
10ba74c21e memofs: Add description 2020-03-10 18:11:39 -07:00
Lucien Greathouse
6191b6371d Update using cargo-readme 2020-03-10 18:09:04 -07:00
Lucien Greathouse
5be4175ac3 Rename vfs -> memofs across the codebase 2020-03-10 18:05:31 -07:00
Lucien Greathouse
f61f3671a6 Choose 'memofs' as the vfs name 2020-03-10 17:57:57 -07:00
Lucien Greathouse
477e0ada32 VFS in external crate (#297)
* vroom

* Port dir middleware

* Filter rules

* Directory metadata

* Project support

* Enable Lua support

* StringValue support

* CSV

* rbxm, rbxmx, and rbxlx

* JSON models

* Clean up some warnings

* Strip out PathMap

* Unwatch paths when they're reported as removed

* Fix 'rojo upload' behavior

* Upgrade to Insta 0.13.1

* Update dependencies

* Release 0.6.0-alpha.2

* Fix bad merge

* Replace MemoryBackend with InMemoryFs

* Sledgehammer tests into passing for now

* Txt middleware

* Update easy snapshot tests

* Lua tests

* Project middleware tests

* Try to fix test failures by sorting

* Port first set of serve session tests

* Add InMemoryFs::raise_event

* Finish porting serve session tests

* Remove UI code for introspecting VFS for now

* VFS docs
2020-03-10 17:38:49 -07:00
Lucien Greathouse
a884f693ae Update changelog 2020-03-08 18:34:11 -07:00
Lucien Greathouse
3107b1b21b Dynamic theming. Closes #241.
Upgrades to Roact master and introduces dynamic theme switching.

We branch on the theme name in order to try to use Rojo's brand
colors instead of Studio's. I kind of winged it with these colors
and we might want to choose slightly nicer dark theme colors
in the future.

I also took the opportunity to reorganize the color naming scheme
since it didn't really work for dark theme.
2020-03-08 18:32:42 -07:00
Lucien Greathouse
04529de7b3 Update Changelog 2020-03-08 17:48:50 -07:00
Lucien Greathouse
199a39208c Implement 'rojo build --watch' (#284)
* Refactor build command to reproduce model more easily

* Spawn ServeSession for building
2020-03-08 17:48:14 -07:00
85 changed files with 2698 additions and 3395 deletions

View File

@@ -1,6 +1,24 @@
# Rojo Changelog # Rojo Changelog
## Unreleased Changes for 0.6.x ## Unreleased Changes for 0.6.x
* Added basic settings panel to plugin, with two settings:
* "Open Scripts Externally": When enabled, opening a script in Studio will instead open it in your default text editor.
* "Two-Way Sync": When enabled, Rojo will attempt to save changes to your place back to the filesystem. **Very early feature, very broken, beware!**
* Added `--color` option to force-enable or force-disable color in Rojo's output.
* The server half of **experimental** two-way sync is now enabled by default.
* Increased default logging verbosity in commands like `rojo build`.
## [0.6.0 Alpha 3](https://github.com/rojo-rbx/rojo/releases/tag/v0.6.0-alpha.3) (March 13, 2020)
* Added `--watch` argument to `rojo build`. ([#284](https://github.com/rojo-rbx/rojo/pull/284))
* Added dark theme support to plugin. ([#241](https://github.com/rojo-rbx/rojo/issues/241))
* Added a revamped `rojo init` command, which will now create more complete projects.
* Added the `rojo doc` command, which opens Rojo's documentation in your browser.
* Fixed many crashes from malformed projects and filesystem edge cases in `rojo serve`.
* Simplified filesystem access code dramatically.
* Improved error reporting and logging across the board.
* Log messages have a less noisy prefix.
* Any thread panicking now causes Rojo to abort instead of existing as a zombie.
* Errors now have a list of causes, helping make many errors more clear.
## [0.6.0 Alpha 2](https://github.com/rojo-rbx/rojo/releases/tag/v0.6.0-alpha.2) (March 6, 2020) ## [0.6.0 Alpha 2](https://github.com/rojo-rbx/rojo/releases/tag/v0.6.0-alpha.2) (March 6, 2020)
* Fixed `rojo upload` command always uploading models. * Fixed `rojo upload` command always uploading models.

View File

@@ -33,15 +33,13 @@ Please file issues and we'll try to help figure out what the best way forward is
## Pushing a Rojo Release ## Pushing a Rojo Release
The Rojo release process is pretty manual right now. If you need to do it, here's how: The Rojo release process is pretty manual right now. If you need to do it, here's how:
1. Bump server version in [`server/Cargo.toml`](server/Cargo.toml) 1. Bump server version in [`Cargo.toml`](Cargo.toml)
2. Bump plugin version in [`plugin/src/Config.lua`](plugin/src/Config.lua) 2. Bump plugin version in [`plugin/src/Config.lua`](plugin/src/Config.lua)
3. Run `cargo test` to update `Cargo.lock` and double-check tests 3. Run `cargo test` to update `Cargo.lock` and double-check tests
4. Update [`CHANGELOG.md`](CHANGELOG.md) 4. Update [`CHANGELOG.md`](CHANGELOG.md)
5. Commit! 5. Commit!
* `git add . && git commit -m "Release vX.Y.Z"` * `git add . && git commit -m "Release vX.Y.Z"`
6. Tag the commit with the version from `Cargo.toml` prepended with a v, like `v0.4.13` 6. Tag the commit with the version from `Cargo.toml` prepended with a v, like `v0.4.13`
7. Build Windows release build of CLI
* `cargo build --release`
7. Publish the CLI 7. Publish the CLI
* `cargo publish` * `cargo publish`
8. Build and upload the plugin 8. Build and upload the plugin
@@ -52,4 +50,5 @@ The Rojo release process is pretty manual right now. If you need to do it, here'
10. Copy GitHub release content from previous release 10. Copy GitHub release content from previous release
* Update the leading text with a summary about the release * Update the leading text with a summary about the release
* Paste the changelog notes (as-is!) from [`CHANGELOG.md`](CHANGELOG.md) * Paste the changelog notes (as-is!) from [`CHANGELOG.md`](CHANGELOG.md)
* Write a small summary of each major feature * Write a small summary of each major feature
* Attach release artifacts from GitHub Actions for each platform

86
Cargo.lock generated
View File

@@ -21,6 +21,11 @@ dependencies = [
"winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)", "winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)",
] ]
[[package]]
name = "anyhow"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]] [[package]]
name = "atty" name = "atty"
version = "0.2.14" version = "0.2.14"
@@ -195,7 +200,7 @@ dependencies = [
name = "clibrojo" name = "clibrojo"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"rojo 0.6.0-alpha.2", "rojo 0.6.0-alpha.3",
] ]
[[package]] [[package]]
@@ -434,18 +439,6 @@ dependencies = [
"cfg-if 0.1.10 (registry+https://github.com/rust-lang/crates.io-index)", "cfg-if 0.1.10 (registry+https://github.com/rust-lang/crates.io-index)",
] ]
[[package]]
name = "env_logger"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"atty 0.2.14 (registry+https://github.com/rust-lang/crates.io-index)",
"humantime 1.3.0 (registry+https://github.com/rust-lang/crates.io-index)",
"log 0.4.8 (registry+https://github.com/rust-lang/crates.io-index)",
"regex 1.3.4 (registry+https://github.com/rust-lang/crates.io-index)",
"termcolor 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]] [[package]]
name = "env_logger" name = "env_logger"
version = "0.7.1" version = "0.7.1"
@@ -532,6 +525,11 @@ name = "foreign-types-shared"
version = "0.1.1" version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "fs-err"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]] [[package]]
name = "fsevent" name = "fsevent"
version = "0.4.0" version = "0.4.0"
@@ -922,6 +920,15 @@ dependencies = [
"rustc_version 0.2.3 (registry+https://github.com/rust-lang/crates.io-index)", "rustc_version 0.2.3 (registry+https://github.com/rust-lang/crates.io-index)",
] ]
[[package]]
name = "memofs"
version = "0.1.1"
dependencies = [
"crossbeam-channel 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)",
"fs-err 2.3.0 (registry+https://github.com/rust-lang/crates.io-index)",
"notify 4.0.15 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]] [[package]]
name = "mime" name = "mime"
version = "0.3.16" version = "0.3.16"
@@ -1065,6 +1072,14 @@ name = "opaque-debug"
version = "0.2.3" version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "opener"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"winapi 0.3.8 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]] [[package]]
name = "openssl" name = "openssl"
version = "0.10.28" version = "0.10.28"
@@ -1655,12 +1670,15 @@ dependencies = [
[[package]] [[package]]
name = "rojo" name = "rojo"
version = "0.6.0-alpha.2" version = "0.6.0-alpha.3"
dependencies = [ dependencies = [
"anyhow 1.0.27 (registry+https://github.com/rust-lang/crates.io-index)",
"backtrace 0.3.45 (registry+https://github.com/rust-lang/crates.io-index)",
"criterion 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)", "criterion 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)",
"crossbeam-channel 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)", "crossbeam-channel 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)",
"csv 1.1.3 (registry+https://github.com/rust-lang/crates.io-index)", "csv 1.1.3 (registry+https://github.com/rust-lang/crates.io-index)",
"env_logger 0.7.1 (registry+https://github.com/rust-lang/crates.io-index)", "env_logger 0.7.1 (registry+https://github.com/rust-lang/crates.io-index)",
"fs-err 2.3.0 (registry+https://github.com/rust-lang/crates.io-index)",
"futures 0.1.29 (registry+https://github.com/rust-lang/crates.io-index)", "futures 0.1.29 (registry+https://github.com/rust-lang/crates.io-index)",
"globset 0.4.4 (registry+https://github.com/rust-lang/crates.io-index)", "globset 0.4.4 (registry+https://github.com/rust-lang/crates.io-index)",
"humantime 1.3.0 (registry+https://github.com/rust-lang/crates.io-index)", "humantime 1.3.0 (registry+https://github.com/rust-lang/crates.io-index)",
@@ -1670,7 +1688,9 @@ dependencies = [
"lazy_static 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)", "lazy_static 1.4.0 (registry+https://github.com/rust-lang/crates.io-index)",
"log 0.4.8 (registry+https://github.com/rust-lang/crates.io-index)", "log 0.4.8 (registry+https://github.com/rust-lang/crates.io-index)",
"maplit 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)", "maplit 1.0.2 (registry+https://github.com/rust-lang/crates.io-index)",
"memofs 0.1.1",
"notify 4.0.15 (registry+https://github.com/rust-lang/crates.io-index)", "notify 4.0.15 (registry+https://github.com/rust-lang/crates.io-index)",
"opener 0.4.1 (registry+https://github.com/rust-lang/crates.io-index)",
"paste 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)", "paste 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)",
"pretty_assertions 0.6.1 (registry+https://github.com/rust-lang/crates.io-index)", "pretty_assertions 0.6.1 (registry+https://github.com/rust-lang/crates.io-index)",
"rbx_binary 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)", "rbx_binary 0.5.0 (registry+https://github.com/rust-lang/crates.io-index)",
@@ -1685,10 +1705,10 @@ dependencies = [
"serde 1.0.104 (registry+https://github.com/rust-lang/crates.io-index)", "serde 1.0.104 (registry+https://github.com/rust-lang/crates.io-index)",
"serde_json 1.0.48 (registry+https://github.com/rust-lang/crates.io-index)", "serde_json 1.0.48 (registry+https://github.com/rust-lang/crates.io-index)",
"serde_yaml 0.8.11 (registry+https://github.com/rust-lang/crates.io-index)", "serde_yaml 0.8.11 (registry+https://github.com/rust-lang/crates.io-index)",
"snafu 0.6.2 (registry+https://github.com/rust-lang/crates.io-index)",
"structopt 0.3.11 (registry+https://github.com/rust-lang/crates.io-index)", "structopt 0.3.11 (registry+https://github.com/rust-lang/crates.io-index)",
"tempfile 3.1.0 (registry+https://github.com/rust-lang/crates.io-index)", "tempfile 3.1.0 (registry+https://github.com/rust-lang/crates.io-index)",
"termcolor 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)", "termcolor 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)",
"thiserror 1.0.11 (registry+https://github.com/rust-lang/crates.io-index)",
"tokio 0.1.22 (registry+https://github.com/rust-lang/crates.io-index)", "tokio 0.1.22 (registry+https://github.com/rust-lang/crates.io-index)",
"uuid 0.8.1 (registry+https://github.com/rust-lang/crates.io-index)", "uuid 0.8.1 (registry+https://github.com/rust-lang/crates.io-index)",
"walkdir 2.3.1 (registry+https://github.com/rust-lang/crates.io-index)", "walkdir 2.3.1 (registry+https://github.com/rust-lang/crates.io-index)",
@@ -1707,13 +1727,13 @@ dependencies = [
name = "rojo-test" name = "rojo-test"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"env_logger 0.6.2 (registry+https://github.com/rust-lang/crates.io-index)", "env_logger 0.7.1 (registry+https://github.com/rust-lang/crates.io-index)",
"insta 0.13.1 (registry+https://github.com/rust-lang/crates.io-index)", "insta 0.13.1 (registry+https://github.com/rust-lang/crates.io-index)",
"log 0.4.8 (registry+https://github.com/rust-lang/crates.io-index)", "log 0.4.8 (registry+https://github.com/rust-lang/crates.io-index)",
"paste 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)", "paste 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)",
"rbx_dom_weak 1.10.1 (registry+https://github.com/rust-lang/crates.io-index)", "rbx_dom_weak 1.10.1 (registry+https://github.com/rust-lang/crates.io-index)",
"reqwest 0.9.24 (registry+https://github.com/rust-lang/crates.io-index)", "reqwest 0.9.24 (registry+https://github.com/rust-lang/crates.io-index)",
"rojo 0.6.0-alpha.2", "rojo 0.6.0-alpha.3",
"rojo-insta-ext 0.1.0", "rojo-insta-ext 0.1.0",
"serde 1.0.104 (registry+https://github.com/rust-lang/crates.io-index)", "serde 1.0.104 (registry+https://github.com/rust-lang/crates.io-index)",
"serde_json 1.0.48 (registry+https://github.com/rust-lang/crates.io-index)", "serde_json 1.0.48 (registry+https://github.com/rust-lang/crates.io-index)",
@@ -2039,6 +2059,24 @@ dependencies = [
"unicode-width 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)", "unicode-width 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)",
] ]
[[package]]
name = "thiserror"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"thiserror-impl 1.0.11 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]]
name = "thiserror-impl"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
dependencies = [
"proc-macro2 1.0.9 (registry+https://github.com/rust-lang/crates.io-index)",
"quote 1.0.3 (registry+https://github.com/rust-lang/crates.io-index)",
"syn 1.0.16 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]] [[package]]
name = "thread_local" name = "thread_local"
version = "1.0.1" version = "1.0.1"
@@ -2364,14 +2402,6 @@ name = "version_check"
version = "0.9.1" version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
[[package]]
name = "vfs"
version = "0.1.0"
dependencies = [
"crossbeam-channel 0.4.2 (registry+https://github.com/rust-lang/crates.io-index)",
"notify 4.0.15 (registry+https://github.com/rust-lang/crates.io-index)",
]
[[package]] [[package]]
name = "walkdir" name = "walkdir"
version = "2.3.1" version = "2.3.1"
@@ -2526,6 +2556,7 @@ dependencies = [
"checksum adler32 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)" = "5d2e7343e7fc9de883d1b0341e0b13970f764c14101234857d2ddafa1cb1cac2" "checksum adler32 1.0.4 (registry+https://github.com/rust-lang/crates.io-index)" = "5d2e7343e7fc9de883d1b0341e0b13970f764c14101234857d2ddafa1cb1cac2"
"checksum aho-corasick 0.7.9 (registry+https://github.com/rust-lang/crates.io-index)" = "d5e63fd144e18ba274ae7095c0197a870a7b9468abc801dd62f190d80817d2ec" "checksum aho-corasick 0.7.9 (registry+https://github.com/rust-lang/crates.io-index)" = "d5e63fd144e18ba274ae7095c0197a870a7b9468abc801dd62f190d80817d2ec"
"checksum ansi_term 0.11.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b" "checksum ansi_term 0.11.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ee49baf6cb617b853aa8d93bf420db2383fab46d314482ca2803b40d5fde979b"
"checksum anyhow 1.0.27 (registry+https://github.com/rust-lang/crates.io-index)" = "013a6e0a2cbe3d20f9c60b65458f7a7f7a5e636c5d0f45a5a6aee5d4b1f01785"
"checksum atty 0.2.14 (registry+https://github.com/rust-lang/crates.io-index)" = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" "checksum atty 0.2.14 (registry+https://github.com/rust-lang/crates.io-index)" = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8"
"checksum autocfg 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)" = "1d49d90015b3c36167a20fe2810c5cd875ad504b39cff3d4eae7977e6b7c1cb2" "checksum autocfg 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)" = "1d49d90015b3c36167a20fe2810c5cd875ad504b39cff3d4eae7977e6b7c1cb2"
"checksum autocfg 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "f8aac770f1885fd7e387acedd76065302551364496e46b3dd00860b2f8359b9d" "checksum autocfg 1.0.0 (registry+https://github.com/rust-lang/crates.io-index)" = "f8aac770f1885fd7e387acedd76065302551364496e46b3dd00860b2f8359b9d"
@@ -2573,7 +2604,6 @@ dependencies = [
"checksum either 1.5.3 (registry+https://github.com/rust-lang/crates.io-index)" = "bb1f6b1ce1c140482ea30ddd3335fc0024ac7ee112895426e0a629a6c20adfe3" "checksum either 1.5.3 (registry+https://github.com/rust-lang/crates.io-index)" = "bb1f6b1ce1c140482ea30ddd3335fc0024ac7ee112895426e0a629a6c20adfe3"
"checksum encode_unicode 0.3.6 (registry+https://github.com/rust-lang/crates.io-index)" = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" "checksum encode_unicode 0.3.6 (registry+https://github.com/rust-lang/crates.io-index)" = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f"
"checksum encoding_rs 0.8.22 (registry+https://github.com/rust-lang/crates.io-index)" = "cd8d03faa7fe0c1431609dfad7bbe827af30f82e1e2ae6f7ee4fca6bd764bc28" "checksum encoding_rs 0.8.22 (registry+https://github.com/rust-lang/crates.io-index)" = "cd8d03faa7fe0c1431609dfad7bbe827af30f82e1e2ae6f7ee4fca6bd764bc28"
"checksum env_logger 0.6.2 (registry+https://github.com/rust-lang/crates.io-index)" = "aafcde04e90a5226a6443b7aabdb016ba2f8307c847d524724bd9b346dd1a2d3"
"checksum env_logger 0.7.1 (registry+https://github.com/rust-lang/crates.io-index)" = "44533bbbb3bb3c1fa17d9f2e4e38bbbaf8396ba82193c4cb1b6445d711445d36" "checksum env_logger 0.7.1 (registry+https://github.com/rust-lang/crates.io-index)" = "44533bbbb3bb3c1fa17d9f2e4e38bbbaf8396ba82193c4cb1b6445d711445d36"
"checksum error-chain 0.12.2 (registry+https://github.com/rust-lang/crates.io-index)" = "d371106cc88ffdfb1eabd7111e432da544f16f3e2d7bf1dfe8bf575f1df045cd" "checksum error-chain 0.12.2 (registry+https://github.com/rust-lang/crates.io-index)" = "d371106cc88ffdfb1eabd7111e432da544f16f3e2d7bf1dfe8bf575f1df045cd"
"checksum failure 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)" = "b8529c2421efa3066a5cbd8063d2244603824daccb6936b079010bb2aa89464b" "checksum failure 0.1.7 (registry+https://github.com/rust-lang/crates.io-index)" = "b8529c2421efa3066a5cbd8063d2244603824daccb6936b079010bb2aa89464b"
@@ -2584,6 +2614,7 @@ dependencies = [
"checksum fnv 1.0.6 (registry+https://github.com/rust-lang/crates.io-index)" = "2fad85553e09a6f881f739c29f0b00b0f01357c743266d478b68951ce23285f3" "checksum fnv 1.0.6 (registry+https://github.com/rust-lang/crates.io-index)" = "2fad85553e09a6f881f739c29f0b00b0f01357c743266d478b68951ce23285f3"
"checksum foreign-types 0.3.2 (registry+https://github.com/rust-lang/crates.io-index)" = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" "checksum foreign-types 0.3.2 (registry+https://github.com/rust-lang/crates.io-index)" = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
"checksum foreign-types-shared 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" "checksum foreign-types-shared 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
"checksum fs-err 2.3.0 (registry+https://github.com/rust-lang/crates.io-index)" = "c1a51f8b7158efbe531f7baa74e38e49fbc41239e5d66720bb37ed39c27c241a"
"checksum fsevent 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "5ab7d1bd1bd33cc98b0889831b72da23c0aa4df9cec7e0702f46ecea04b35db6" "checksum fsevent 0.4.0 (registry+https://github.com/rust-lang/crates.io-index)" = "5ab7d1bd1bd33cc98b0889831b72da23c0aa4df9cec7e0702f46ecea04b35db6"
"checksum fsevent-sys 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)" = "f41b048a94555da0f42f1d632e2e19510084fb8e303b0daa2816e733fb3644a0" "checksum fsevent-sys 2.0.1 (registry+https://github.com/rust-lang/crates.io-index)" = "f41b048a94555da0f42f1d632e2e19510084fb8e303b0daa2816e733fb3644a0"
"checksum fuchsia-cprng 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba" "checksum fuchsia-cprng 0.1.1 (registry+https://github.com/rust-lang/crates.io-index)" = "a06f77d526c1a601b7c4cdd98f54b5eaabffc14d5f2f0296febdc7f357c6d3ba"
@@ -2645,6 +2676,7 @@ dependencies = [
"checksum num_cpus 1.12.0 (registry+https://github.com/rust-lang/crates.io-index)" = "46203554f085ff89c235cd12f7075f3233af9b11ed7c9e16dfe2560d03313ce6" "checksum num_cpus 1.12.0 (registry+https://github.com/rust-lang/crates.io-index)" = "46203554f085ff89c235cd12f7075f3233af9b11ed7c9e16dfe2560d03313ce6"
"checksum oorandom 11.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ebcec7c9c2a95cacc7cd0ecb89d8a8454eca13906f6deb55258ffff0adeb9405" "checksum oorandom 11.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "ebcec7c9c2a95cacc7cd0ecb89d8a8454eca13906f6deb55258ffff0adeb9405"
"checksum opaque-debug 0.2.3 (registry+https://github.com/rust-lang/crates.io-index)" = "2839e79665f131bdb5782e51f2c6c9599c133c6098982a54c794358bf432529c" "checksum opaque-debug 0.2.3 (registry+https://github.com/rust-lang/crates.io-index)" = "2839e79665f131bdb5782e51f2c6c9599c133c6098982a54c794358bf432529c"
"checksum opener 0.4.1 (registry+https://github.com/rust-lang/crates.io-index)" = "13117407ca9d0caf3a0e74f97b490a7e64c0ae3aa90a8b7085544d0c37b6f3ae"
"checksum openssl 0.10.28 (registry+https://github.com/rust-lang/crates.io-index)" = "973293749822d7dd6370d6da1e523b0d1db19f06c459134c658b2a4261378b52" "checksum openssl 0.10.28 (registry+https://github.com/rust-lang/crates.io-index)" = "973293749822d7dd6370d6da1e523b0d1db19f06c459134c658b2a4261378b52"
"checksum openssl-probe 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)" = "77af24da69f9d9341038eba93a073b1fdaaa1b788221b00a69bce9e762cb32de" "checksum openssl-probe 0.1.2 (registry+https://github.com/rust-lang/crates.io-index)" = "77af24da69f9d9341038eba93a073b1fdaaa1b788221b00a69bce9e762cb32de"
"checksum openssl-sys 0.9.54 (registry+https://github.com/rust-lang/crates.io-index)" = "1024c0a59774200a555087a6da3f253a9095a5f344e353b212ac4c8b8e450986" "checksum openssl-sys 0.9.54 (registry+https://github.com/rust-lang/crates.io-index)" = "1024c0a59774200a555087a6da3f253a9095a5f344e353b212ac4c8b8e450986"
@@ -2741,6 +2773,8 @@ dependencies = [
"checksum termcolor 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "bb6bfa289a4d7c5766392812c0a1f4c1ba45afa1ad47803c11e1f407d846d75f" "checksum termcolor 1.1.0 (registry+https://github.com/rust-lang/crates.io-index)" = "bb6bfa289a4d7c5766392812c0a1f4c1ba45afa1ad47803c11e1f407d846d75f"
"checksum termios 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)" = "72b620c5ea021d75a735c943269bb07d30c9b77d6ac6b236bc8b5c496ef05625" "checksum termios 0.3.1 (registry+https://github.com/rust-lang/crates.io-index)" = "72b620c5ea021d75a735c943269bb07d30c9b77d6ac6b236bc8b5c496ef05625"
"checksum textwrap 0.11.0 (registry+https://github.com/rust-lang/crates.io-index)" = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" "checksum textwrap 0.11.0 (registry+https://github.com/rust-lang/crates.io-index)" = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060"
"checksum thiserror 1.0.11 (registry+https://github.com/rust-lang/crates.io-index)" = "ee14bf8e6767ab4c687c9e8bc003879e042a96fd67a3ba5934eadb6536bef4db"
"checksum thiserror-impl 1.0.11 (registry+https://github.com/rust-lang/crates.io-index)" = "a7b51e1fbc44b5a0840be594fbc0f960be09050f2617e61e6aa43bef97cd3ef4"
"checksum thread_local 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)" = "d40c6d1b69745a6ec6fb1ca717914848da4b44ae29d9b3080cbee91d72a69b14" "checksum thread_local 1.0.1 (registry+https://github.com/rust-lang/crates.io-index)" = "d40c6d1b69745a6ec6fb1ca717914848da4b44ae29d9b3080cbee91d72a69b14"
"checksum time 0.1.42 (registry+https://github.com/rust-lang/crates.io-index)" = "db8dcfca086c1143c9270ac42a2bbd8a7ee477b78ac8e45b19abfb0cbede4b6f" "checksum time 0.1.42 (registry+https://github.com/rust-lang/crates.io-index)" = "db8dcfca086c1143c9270ac42a2bbd8a7ee477b78ac8e45b19abfb0cbede4b6f"
"checksum tinytemplate 1.0.3 (registry+https://github.com/rust-lang/crates.io-index)" = "57a3c6667d3e65eb1bc3aed6fd14011c6cbc3a0665218ab7f5daf040b9ec371a" "checksum tinytemplate 1.0.3 (registry+https://github.com/rust-lang/crates.io-index)" = "57a3c6667d3e65eb1bc3aed6fd14011c6cbc3a0665218ab7f5daf040b9ec371a"

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "rojo" name = "rojo"
version = "0.6.0-alpha.2" version = "0.6.0-alpha.3"
authors = ["Lucien Greathouse <me@lpghatguy.com>"] authors = ["Lucien Greathouse <me@lpghatguy.com>"]
description = "Enables professional-grade development tools for Roblox developers" description = "Enables professional-grade development tools for Roblox developers"
license = "MPL-2.0" license = "MPL-2.0"
@@ -15,15 +15,18 @@ exclude = [
"/test-projects/**", "/test-projects/**",
] ]
[profile.dev]
panic = "abort"
[profile.release]
panic = "abort"
[features] [features]
default = [] default = []
# Turn on support for specifying glob ignore path rules in the project format. # Turn on support for specifying glob ignore path rules in the project format.
unstable_glob_ignore_paths = [] unstable_glob_ignore_paths = []
# Turn on the server half of Rojo's unstable two-way sync feature.
unstable_two_way_sync = []
# Enable this feature to live-reload assets from the web UI. # Enable this feature to live-reload assets from the web UI.
dev_live_assets = [] dev_live_assets = []
@@ -32,14 +35,14 @@ members = [
"rojo-test", "rojo-test",
"rojo-insta-ext", "rojo-insta-ext",
"clibrojo", "clibrojo",
"vfs", "memofs",
] ]
default-members = [ default-members = [
".", ".",
"rojo-test", "rojo-test",
"rojo-insta-ext", "rojo-insta-ext",
"vfs", "memofs",
] ]
[lib] [lib]
@@ -55,9 +58,14 @@ name = "build"
harness = false harness = false
[dependencies] [dependencies]
memofs = { version = "0.1.1", path = "memofs" }
anyhow = "1.0.27"
backtrace = "0.3"
crossbeam-channel = "0.4.0" crossbeam-channel = "0.4.0"
csv = "1.1.1" csv = "1.1.1"
env_logger = "0.7.1" env_logger = "0.7.1"
fs-err = "2.2.0"
futures = "0.1.29" futures = "0.1.29"
globset = "0.4.4" globset = "0.4.4"
humantime = "1.3.0" humantime = "1.3.0"
@@ -67,6 +75,7 @@ lazy_static = "1.4.0"
log = "0.4.8" log = "0.4.8"
maplit = "1.0.1" maplit = "1.0.1"
notify = "4.0.14" notify = "4.0.14"
opener = "0.4.1"
rbx_binary = "0.5.0" rbx_binary = "0.5.0"
rbx_dom_weak = "1.10.1" rbx_dom_weak = "1.10.1"
rbx_reflection = "3.3.408" rbx_reflection = "3.3.408"
@@ -77,9 +86,10 @@ ritz = "0.1.0"
rlua = "0.17.0" rlua = "0.17.0"
serde = { version = "1.0", features = ["derive", "rc"] } serde = { version = "1.0", features = ["derive", "rc"] }
serde_json = "1.0" serde_json = "1.0"
snafu = "0.6.0"
structopt = "0.3.5" structopt = "0.3.5"
termcolor = "1.0.5" termcolor = "1.0.5"
thiserror = "1.0.11"
tokio = "0.1.22"
uuid = { version = "0.8.1", features = ["v4", "serde"] } uuid = { version = "0.8.1", features = ["v4", "serde"] }
[target.'cfg(windows)'.dependencies] [target.'cfg(windows)'.dependencies]
@@ -95,5 +105,4 @@ paste = "0.1"
pretty_assertions = "0.6.1" pretty_assertions = "0.6.1"
serde_yaml = "0.8.9" serde_yaml = "0.8.9"
tempfile = "3.0" tempfile = "3.0"
tokio = "0.1.22"
walkdir = "2.1" walkdir = "2.1"

View File

@@ -13,8 +13,8 @@
<a href="https://crates.io/crates/rojo"> <a href="https://crates.io/crates/rojo">
<img src="https://img.shields.io/crates/v/rojo.svg?label=latest%20release" alt="Latest server version" /> <img src="https://img.shields.io/crates/v/rojo.svg?label=latest%20release" alt="Latest server version" />
</a> </a>
<a href="https://rojo.space/docs/0.5.x"> <a href="https://rojo.space/docs">
<img src="https://img.shields.io/badge/docs-0.5.x-brightgreen.svg" alt="Rojo 0.5.x Documentation" /> <img src="https://img.shields.io/badge/docs-website-brightgreen.svg" alt="Rojo 0.5.x Documentation" />
</a> </a>
</div> </div>
@@ -34,11 +34,10 @@ Rojo enables:
* Streaming `rbxmx` and `rbxm` models into your game in real time * Streaming `rbxmx` and `rbxm` models into your game in real time
* Packaging and deploying your project to Roblox.com from the command line * Packaging and deploying your project to Roblox.com from the command line
Soon, Rojo will be able to: In the future, Rojo will be able to:
* Automatically convert your existing game to work with Rojo
* Sync instances from Roblox Studio to the filesystem * Sync instances from Roblox Studio to the filesystem
* Automatically manage your assets on Roblox.com, like images and sounds * Automatically convert your existing game to work with Rojo
* Import custom instances like MoonScript code * Import custom instances like MoonScript code
## [Documentation](https://rojo.space/docs) ## [Documentation](https://rojo.space/docs)

View File

@@ -0,0 +1,11 @@
# {project_name}
Generated by [Rojo](https://github.com/rojo-rbx/rojo) {rojo_version}.
## Getting Started
To build this library or plugin, use:
```bash
rojo build -o "{project_name}.rbxmx"
```
For more help, check out [the Rojo documentation](https://rojo.space/docs).

View File

@@ -0,0 +1,6 @@
{
"name": "{project_name}",
"tree": {
"$path": "src"
}
}

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
# {project_name}
Generated by [Rojo](https://github.com/rojo-rbx/rojo) {rojo_version}.
## Getting Started
To build the place from scratch, use:
```bash
rojo build -o "{project_name}.rbxlx"
```
Next, open `{project_name}.rbxlx` in Roblox Studio and start the Rojo server:
```bash
rojo serve
```
For more help, check out [the Rojo documentation](https://rojo.space/docs).

View File

@@ -1,39 +1,36 @@
{ {
"name": "[placeholder]", "name": "{project_name}",
"tree": { "tree": {
"$className": "DataModel", "$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": { "ReplicatedStorage": {
"$className": "ReplicatedStorage", "$className": "ReplicatedStorage",
"Source": {
"$path": "src" "Common": {
"$path": "src/common"
} }
}, },
"SoundService": {
"$className": "SoundService", "ServerScriptService": {
"$properties": { "$className": "ServerScriptService",
"RespectFilteringEnabled": true
"Server": {
"$path": "src/server"
} }
}, },
"StarterPlayer": {
"$className": "StarterPlayer",
"StarterPlayerScripts": {
"$className": "StarterPlayerScripts",
"Client": {
"$path": "src/client"
}
}
},
"Workspace": { "Workspace": {
"$className": "Workspace", "$className": "Workspace",
"$properties": { "$properties": {
@@ -61,6 +58,32 @@
] ]
} }
} }
},
"Lighting": {
"$className": "Lighting",
"$properties": {
"Ambient": [
0,
0,
0
],
"Brightness": 2,
"GlobalShadows": true,
"Outlines": false,
"Technology": "Voxel"
}
},
"SoundService": {
"$className": "SoundService",
"$properties": {
"RespectFilteringEnabled": true
}
},
"HttpService": {
"$className": "HttpService",
"$properties": {
"HttpEnabled": true
}
} }
} }
} }

View File

@@ -0,0 +1,6 @@
# Project place file
/{project_name}.rbxlx
# Roblox Studio lock files
/*.rbxlx.lock
/*.rbxl.lock

View File

@@ -1,30 +0,0 @@
digraph Rojo {
concentrate = true;
node [fontname = "sans-serif"];
plugin [label="Roblox Studio Plugin"]
session [label="Session"]
rbx_tree [label="Instance Tree"]
imfs [label="In-Memory Filesystem"]
fs_impl [label="Filesystem Implementation\n(stubbed in tests)"]
fs [label="Real Filesystem"]
snapshot_subsystem [label="Snapshot Subsystem\n(reconciler)"]
snapshot_generator [label="Snapshot Generator"]
user_middleware [label="User Middleware\n(MoonScript, etc.)"]
builtin_middleware [label="Built-in Middleware\n(.lua, .rbxm, etc.)"]
api [label="Web API"]
file_watcher [label="File Watcher"]
session -> imfs
session -> rbx_tree
session -> snapshot_subsystem
session -> snapshot_generator
session -> file_watcher [dir="both"]
file_watcher -> imfs
snapshot_generator -> user_middleware
snapshot_generator -> builtin_middleware
plugin -> api [style="dotted"; dir="both"; minlen=2]
api -> session
imfs -> fs_impl
fs_impl -> fs
}

9
memofs/CHANGELOG.md Normal file
View File

@@ -0,0 +1,9 @@
# memofs Changelog
## Unreleased Changes
## 0.1.1 (2020-03-18)
* Improved error messages using the [fs-err](https://crates.io/crates/fs-err) crate.
## 0.1.0 (2020-03-10)
* Initial release

View File

@@ -1,14 +1,16 @@
[package] [package]
name = "vfs" name = "memofs"
version = "0.1.0" description = "Virtual filesystem with configurable backends."
version = "0.1.1"
authors = ["Lucien Greathouse <me@lpghatguy.com>"] authors = ["Lucien Greathouse <me@lpghatguy.com>"]
edition = "2018" edition = "2018"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"
homepage = "https://github.com/rojo-rbx/rojo" homepage = "https://github.com/rojo-rbx/rojo/tree/master/memofs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies] [dependencies]
notify = "4.0.15"
crossbeam-channel = "0.4.0" crossbeam-channel = "0.4.0"
fs-err = "2.3.0"
notify = "4.0.15"

22
memofs/README.md Normal file
View File

@@ -0,0 +1,22 @@
# memofs
[![Crates.io](https://img.shields.io/crates/v/memofs.svg)](https://crates.io/crates/memofs)
Implementation of a virtual filesystem with a configurable backend and file
watching.
memofs is currently an unstable minimum viable library. Its primary consumer is
[Rojo](https://github.com/rojo-rbx/rojo), a build system for Roblox.
### Current Features
* API similar to `std::fs`
* Configurable backends
* `StdBackend`, which uses `std::fs` and the `notify` crate
* `NoopBackend`, which always throws errors
* `InMemoryFs`, a simple in-memory filesystem useful for testing
### Future Features
* Hash-based hierarchical memoization keys (hence the name)
* Configurable caching (write-through, write-around, write-back)
## License
memofs is available under the terms of the MIT license. See [LICENSE.txt](LICENSE.txt) or <https://opensource.org/licenses/MIT> for more details.

7
memofs/README.tpl Normal file
View File

@@ -0,0 +1,7 @@
# {{crate}}
[![Crates.io](https://img.shields.io/crates/v/memofs.svg)](https://crates.io/crates/memofs)
{{readme}}
## License
memofs is available under the terms of the MIT license. See [LICENSE.txt](LICENSE.txt) or <https://opensource.org/licenses/MIT> for more details.

View File

@@ -1,32 +1,77 @@
use std::collections::{BTreeSet, HashMap, VecDeque}; use std::collections::{BTreeSet, HashMap, VecDeque};
use std::io; use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use crossbeam_channel::{Receiver, Sender};
use crate::{DirEntry, Metadata, ReadDir, VfsBackend, VfsEvent, VfsSnapshot}; use crate::{DirEntry, Metadata, ReadDir, VfsBackend, VfsEvent, VfsSnapshot};
/// `VfsBackend` that reads from an in-memory filesystem, intended for setting /// In-memory filesystem that can be used as a VFS backend.
/// up testing scenarios quickly. ///
#[derive(Debug)] /// Internally reference counted to enable giving a copy to
pub struct MemoryBackend { /// [`Vfs`](struct.Vfs.html) and keeping the original to mutate the filesystem's
entries: HashMap<PathBuf, Entry>, /// state with.
orphans: BTreeSet<PathBuf>, #[derive(Debug, Clone)]
pub struct InMemoryFs {
inner: Arc<Mutex<InMemoryFsInner>>,
} }
impl MemoryBackend { impl InMemoryFs {
/// Create a new empty `InMemoryFs`.
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
entries: HashMap::new(), inner: Arc::new(Mutex::new(InMemoryFsInner::new())),
orphans: BTreeSet::new(),
} }
} }
/// Load a [`VfsSnapshot`](enum.VfsSnapshot.html) into a subtree of the
/// in-memory filesystem.
///
/// This function will return an error if the operations required to apply
/// the snapshot result in errors, like trying to create a file inside a
/// file.
pub fn load_snapshot<P: Into<PathBuf>>( pub fn load_snapshot<P: Into<PathBuf>>(
&mut self, &mut self,
path: P, path: P,
snapshot: VfsSnapshot, snapshot: VfsSnapshot,
) -> io::Result<()> { ) -> io::Result<()> {
let path = path.into(); let mut inner = self.inner.lock().unwrap();
inner.load_snapshot(path.into(), snapshot)
}
/// Raises a filesystem change event.
///
/// If this `InMemoryFs` is being used as the backend of a
/// [`Vfs`](struct.Vfs.html), then any listeners be notified of this event.
pub fn raise_event(&mut self, event: VfsEvent) {
let inner = self.inner.lock().unwrap();
inner.event_sender.send(event).unwrap();
}
}
#[derive(Debug)]
struct InMemoryFsInner {
entries: HashMap<PathBuf, Entry>,
orphans: BTreeSet<PathBuf>,
event_receiver: Receiver<VfsEvent>,
event_sender: Sender<VfsEvent>,
}
impl InMemoryFsInner {
fn new() -> Self {
let (event_sender, event_receiver) = crossbeam_channel::unbounded();
Self {
entries: HashMap::new(),
orphans: BTreeSet::new(),
event_receiver,
event_sender,
}
}
fn load_snapshot(&mut self, path: PathBuf, snapshot: VfsSnapshot) -> io::Result<()> {
if let Some(parent_path) = path.parent() { if let Some(parent_path) = path.parent() {
if let Some(parent_entry) = self.entries.get_mut(parent_path) { if let Some(parent_entry) = self.entries.get_mut(parent_path) {
if let Entry::Dir { children } = parent_entry { if let Entry::Dir { children } = parent_entry {
@@ -84,9 +129,11 @@ enum Entry {
Dir { children: BTreeSet<PathBuf> }, Dir { children: BTreeSet<PathBuf> },
} }
impl VfsBackend for MemoryBackend { impl VfsBackend for InMemoryFs {
fn read(&mut self, path: &Path) -> io::Result<Vec<u8>> { fn read(&mut self, path: &Path) -> io::Result<Vec<u8>> {
match self.entries.get(path) { let inner = self.inner.lock().unwrap();
match inner.entries.get(path) {
Some(Entry::File { contents }) => Ok(contents.clone()), Some(Entry::File { contents }) => Ok(contents.clone()),
Some(Entry::Dir { .. }) => must_be_file(path), Some(Entry::Dir { .. }) => must_be_file(path),
None => not_found(path), None => not_found(path),
@@ -94,8 +141,10 @@ impl VfsBackend for MemoryBackend {
} }
fn write(&mut self, path: &Path, data: &[u8]) -> io::Result<()> { fn write(&mut self, path: &Path, data: &[u8]) -> io::Result<()> {
self.load_snapshot( let mut inner = self.inner.lock().unwrap();
path,
inner.load_snapshot(
path.to_path_buf(),
VfsSnapshot::File { VfsSnapshot::File {
contents: data.to_owned(), contents: data.to_owned(),
}, },
@@ -103,7 +152,9 @@ impl VfsBackend for MemoryBackend {
} }
fn read_dir(&mut self, path: &Path) -> io::Result<ReadDir> { fn read_dir(&mut self, path: &Path) -> io::Result<ReadDir> {
match self.entries.get(path) { let inner = self.inner.lock().unwrap();
match inner.entries.get(path) {
Some(Entry::Dir { children }) => { Some(Entry::Dir { children }) => {
let iter = children let iter = children
.clone() .clone()
@@ -120,9 +171,11 @@ impl VfsBackend for MemoryBackend {
} }
fn remove_file(&mut self, path: &Path) -> io::Result<()> { fn remove_file(&mut self, path: &Path) -> io::Result<()> {
match self.entries.get(path) { let mut inner = self.inner.lock().unwrap();
match inner.entries.get(path) {
Some(Entry::File { .. }) => { Some(Entry::File { .. }) => {
self.remove(path.to_owned()); inner.remove(path.to_owned());
Ok(()) Ok(())
} }
Some(Entry::Dir { .. }) => must_be_file(path), Some(Entry::Dir { .. }) => must_be_file(path),
@@ -131,9 +184,11 @@ impl VfsBackend for MemoryBackend {
} }
fn remove_dir_all(&mut self, path: &Path) -> io::Result<()> { fn remove_dir_all(&mut self, path: &Path) -> io::Result<()> {
match self.entries.get(path) { let mut inner = self.inner.lock().unwrap();
match inner.entries.get(path) {
Some(Entry::Dir { .. }) => { Some(Entry::Dir { .. }) => {
self.remove(path.to_owned()); inner.remove(path.to_owned());
Ok(()) Ok(())
} }
Some(Entry::File { .. }) => must_be_dir(path), Some(Entry::File { .. }) => must_be_dir(path),
@@ -142,7 +197,9 @@ impl VfsBackend for MemoryBackend {
} }
fn metadata(&mut self, path: &Path) -> io::Result<Metadata> { fn metadata(&mut self, path: &Path) -> io::Result<Metadata> {
match self.entries.get(path) { let inner = self.inner.lock().unwrap();
match inner.entries.get(path) {
Some(Entry::File { .. }) => Ok(Metadata { is_file: true }), Some(Entry::File { .. }) => Ok(Metadata { is_file: true }),
Some(Entry::Dir { .. }) => Ok(Metadata { is_file: false }), Some(Entry::Dir { .. }) => Ok(Metadata { is_file: false }),
None => not_found(path), None => not_found(path),
@@ -150,7 +207,9 @@ impl VfsBackend for MemoryBackend {
} }
fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> { fn event_receiver(&self) -> crossbeam_channel::Receiver<VfsEvent> {
crossbeam_channel::never() let inner = self.inner.lock().unwrap();
inner.event_receiver.clone()
} }
fn watch(&mut self, _path: &Path) -> io::Result<()> { fn watch(&mut self, _path: &Path) -> io::Result<()> {

View File

@@ -1,4 +1,23 @@
mod memory_backend; /*!
Implementation of a virtual filesystem with a configurable backend and file
watching.
memofs is currently an unstable minimum viable library. Its primary consumer is
[Rojo](https://github.com/rojo-rbx/rojo), a build system for Roblox.
## Current Features
* API similar to `std::fs`
* Configurable backends
* `StdBackend`, which uses `std::fs` and the `notify` crate
* `NoopBackend`, which always throws errors
* `InMemoryFs`, a simple in-memory filesystem useful for testing
## Future Features
* Hash-based hierarchical memoization keys (hence the name)
* Configurable caching (write-through, write-around, write-back)
*/
mod in_memory_fs;
mod noop_backend; mod noop_backend;
mod snapshot; mod snapshot;
mod std_backend; mod std_backend;
@@ -7,7 +26,7 @@ use std::io;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard}; use std::sync::{Arc, Mutex, MutexGuard};
pub use memory_backend::MemoryBackend; pub use in_memory_fs::InMemoryFs;
pub use noop_backend::NoopBackend; pub use noop_backend::NoopBackend;
pub use snapshot::VfsSnapshot; pub use snapshot::VfsSnapshot;
pub use std_backend::StdBackend; pub use std_backend::StdBackend;
@@ -18,9 +37,9 @@ mod sealed {
/// Sealing trait for VfsBackend. /// Sealing trait for VfsBackend.
pub trait Sealed {} pub trait Sealed {}
impl Sealed for MemoryBackend {}
impl Sealed for NoopBackend {} impl Sealed for NoopBackend {}
impl Sealed for StdBackend {} impl Sealed for StdBackend {}
impl Sealed for InMemoryFs {}
} }
/// Trait that transforms `io::Result<T>` into `io::Result<Option<T>>`. /// Trait that transforms `io::Result<T>` into `io::Result<Option<T>>`.
@@ -107,6 +126,8 @@ impl Metadata {
} }
} }
/// Represents an event that a filesystem can raise that might need to be
/// handled.
#[derive(Debug)] #[derive(Debug)]
#[non_exhaustive] #[non_exhaustive]
pub enum VfsEvent { pub enum VfsEvent {
@@ -176,6 +197,10 @@ impl VfsInner {
} }
/// A virtual filesystem with a configurable backend. /// A virtual filesystem with a configurable backend.
///
/// All operations on the Vfs take a lock on an internal backend. For performing
/// large batches of operations, it might be more performant to call `lock()`
/// and use [`VfsLock`](struct.VfsLock.html) instead.
pub struct Vfs { pub struct Vfs {
inner: Mutex<VfsInner>, inner: Mutex<VfsInner>,
} }
@@ -197,6 +222,7 @@ impl Vfs {
} }
} }
/// Manually lock the Vfs, useful for large batches of operations.
pub fn lock(&self) -> VfsLock<'_> { pub fn lock(&self) -> VfsLock<'_> {
VfsLock { VfsLock {
inner: self.inner.lock().unwrap(), inner: self.inner.lock().unwrap(),
@@ -284,7 +310,9 @@ impl Vfs {
} }
} }
/// A locked handle to a `Vfs`, created by `Vfs::lock`. /// A locked handle to a [`Vfs`](struct.Vfs.html), created by `Vfs::lock`.
///
/// Implements roughly the same API as [`Vfs`](struct.Vfs.html).
pub struct VfsLock<'a> { pub struct VfsLock<'a> {
inner: MutexGuard<'a, VfsInner>, inner: MutexGuard<'a, VfsInner>,
} }

View File

@@ -1,5 +1,8 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
/// A slice of a tree of files. Can be loaded into an
/// [`InMemoryFs`](struct.InMemoryFs.html).
#[derive(Debug)]
#[non_exhaustive] #[non_exhaustive]
pub enum VfsSnapshot { pub enum VfsSnapshot {
File { File {
@@ -26,4 +29,16 @@ impl VfsSnapshot {
.collect(), .collect(),
} }
} }
pub fn empty_file() -> Self {
Self::File {
contents: Vec::new(),
}
}
pub fn empty_dir() -> Self {
Self::Dir {
children: BTreeMap::new(),
}
}
} }

View File

@@ -1,4 +1,3 @@
use std::fs;
use std::io; use std::io;
use std::path::Path; use std::path::Path;
use std::sync::mpsc; use std::sync::mpsc;
@@ -55,19 +54,22 @@ impl StdBackend {
impl VfsBackend for StdBackend { impl VfsBackend for StdBackend {
fn read(&mut self, path: &Path) -> io::Result<Vec<u8>> { fn read(&mut self, path: &Path) -> io::Result<Vec<u8>> {
fs::read(path) fs_err::read(path)
} }
fn write(&mut self, path: &Path, data: &[u8]) -> io::Result<()> { fn write(&mut self, path: &Path, data: &[u8]) -> io::Result<()> {
fs::write(path, data) fs_err::write(path, data)
} }
fn read_dir(&mut self, path: &Path) -> io::Result<ReadDir> { fn read_dir(&mut self, path: &Path) -> io::Result<ReadDir> {
let inner = fs::read_dir(path)?.map(|entry| { let entries: Result<Vec<_>, _> = fs_err::read_dir(path)?.collect();
Ok(DirEntry { let mut entries = entries?;
path: entry?.path(),
}) entries.sort_by_cached_key(|entry| entry.file_name());
});
let inner = entries
.into_iter()
.map(|entry| Ok(DirEntry { path: entry.path() }));
Ok(ReadDir { Ok(ReadDir {
inner: Box::new(inner), inner: Box::new(inner),
@@ -75,15 +77,15 @@ impl VfsBackend for StdBackend {
} }
fn remove_file(&mut self, path: &Path) -> io::Result<()> { fn remove_file(&mut self, path: &Path) -> io::Result<()> {
fs::remove_file(path) fs_err::remove_file(path)
} }
fn remove_dir_all(&mut self, path: &Path) -> io::Result<()> { fn remove_dir_all(&mut self, path: &Path) -> io::Result<()> {
fs::remove_dir_all(path) fs_err::remove_dir_all(path)
} }
fn metadata(&mut self, path: &Path) -> io::Result<Metadata> { fn metadata(&mut self, path: &Path) -> io::Result<Metadata> {
let inner = fs::metadata(path)?; let inner = fs_err::metadata(path)?;
Ok(Metadata { Ok(Metadata {
is_file: inner.is_file(), is_file: inner.is_file(),

View File

@@ -233,4 +233,19 @@ function ApiContext:retrieveMessages()
end) end)
end end
function ApiContext:open(id)
local url = ("%s/api/open/%s"):format(self.__baseUrl, id)
return Http.post(url, "")
:andThen(rejectFailedRequests)
:andThen(Http.Response.json)
:andThen(function(body)
if body.sessionId ~= self.__sessionId then
return Promise.reject("Server changed ID")
end
return nil
end)
end
return ApiContext return ApiContext

View File

@@ -17,6 +17,7 @@ local ConnectPanel = require(Plugin.Components.ConnectPanel)
local ConnectingPanel = require(Plugin.Components.ConnectingPanel) local ConnectingPanel = require(Plugin.Components.ConnectingPanel)
local ConnectionActivePanel = require(Plugin.Components.ConnectionActivePanel) local ConnectionActivePanel = require(Plugin.Components.ConnectionActivePanel)
local ErrorPanel = require(Plugin.Components.ErrorPanel) local ErrorPanel = require(Plugin.Components.ErrorPanel)
local SettingsPanel = require(Plugin.Components.SettingsPanel)
local e = Roact.createElement local e = Roact.createElement
@@ -61,6 +62,7 @@ local AppStatus = strict("AppStatus", {
Connecting = "Connecting", Connecting = "Connecting",
Connected = "Connected", Connected = "Connected",
Error = "Error", Error = "Error",
Settings = "Settings",
}) })
local App = Roact.Component:extend("App") local App = Roact.Component:extend("App")
@@ -73,10 +75,7 @@ function App:init()
self.signals = {} self.signals = {}
self.serveSession = nil self.serveSession = nil
self.displayedVersion = Version.display(Config.version)
self.displayedVersion = DevSettings:isEnabled()
and Config.codename
or Version.display(Config.version)
local toolbar = self.props.plugin:CreateToolbar("Rojo " .. self.displayedVersion) local toolbar = self.props.plugin:CreateToolbar("Rojo " .. self.displayedVersion)
@@ -108,12 +107,14 @@ function App:init()
end) end)
end end
function App:startSession(address, port) function App:startSession(address, port, sessionOptions)
Log.trace("Starting new session") Log.trace("Starting new session")
local baseUrl = ("http://%s:%s"):format(address, port) local baseUrl = ("http://%s:%s"):format(address, port)
self.serveSession = ServeSession.new({ self.serveSession = ServeSession.new({
apiContext = ApiContext.new(baseUrl), apiContext = ApiContext.new(baseUrl),
openScriptsExternally = sessionOptions.openScriptsExternally,
twoWaySync = sessionOptions.twoWaySync,
}) })
self.serveSession:onStatusChanged(function(status, details) self.serveSession:onStatusChanged(function(status, details)
@@ -154,8 +155,13 @@ function App:render()
if self.state.appStatus == AppStatus.NotStarted then if self.state.appStatus == AppStatus.NotStarted then
children = { children = {
ConnectPanel = e(ConnectPanel, { ConnectPanel = e(ConnectPanel, {
startSession = function(address, port) startSession = function(address, port, settings)
self:startSession(address, port) self:startSession(address, port, settings)
end,
openSettings = function()
self:setState({
appStatus = AppStatus.Settings,
})
end, end,
cancel = function() cancel = function()
Log.trace("Canceling session configuration") Log.trace("Canceling session configuration")
@@ -168,7 +174,7 @@ function App:render()
} }
elseif self.state.appStatus == AppStatus.Connecting then elseif self.state.appStatus == AppStatus.Connecting then
children = { children = {
ConnectingPanel = Roact.createElement(ConnectingPanel), ConnectingPanel = e(ConnectingPanel),
} }
elseif self.state.appStatus == AppStatus.Connected then elseif self.state.appStatus == AppStatus.Connected then
children = { children = {
@@ -186,9 +192,19 @@ function App:render()
end, end,
}), }),
} }
elseif self.state.appStatus == AppStatus.Settings then
children = {
e(SettingsPanel, {
back = function()
self:setState({
appStatus = AppStatus.NotStarted,
})
end,
}),
}
elseif self.state.appStatus == AppStatus.Error then elseif self.state.appStatus == AppStatus.Error then
children = { children = {
ErrorPanel = Roact.createElement(ErrorPanel, { ErrorPanel = e(ErrorPanel, {
errorMessage = self.state.errorMessage, errorMessage = self.state.errorMessage,
onDismiss = function() onDismiss = function()
self:setState({ self:setState({
@@ -199,7 +215,7 @@ function App:render()
} }
end end
return Roact.createElement(Roact.Portal, { return e(Roact.Portal, {
target = self.dockWidget, target = self.dockWidget,
}, children) }, children)
end end

View File

@@ -0,0 +1,39 @@
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Roact = require(Rojo.Roact)
local Theme = require(Plugin.Components.Theme)
local e = Roact.createElement
local function Checkbox(props)
local checked = props.checked
local layoutOrder = props.layoutOrder
local onChange = props.onChange
return Theme.with(function(theme)
return e("ImageButton", {
LayoutOrder = layoutOrder,
Size = UDim2.new(0, 20, 0, 20),
BorderSizePixel = 2,
BorderColor3 = theme.Text2,
BackgroundColor3 = theme.Background2,
[Roact.Event.Activated] = function()
onChange(not checked)
end,
}, {
Indicator = e("Frame", {
Size = UDim2.new(0, 18, 0, 18),
Position = UDim2.new(0.5, 0, 0.5, 0),
AnchorPoint = Vector2.new(0.5, 0.5),
BorderSizePixel = 0,
BackgroundColor3 = theme.Brand1,
BackgroundTransparency = checked and 0 or 1,
})
})
end)
end
return Checkbox

View File

@@ -4,13 +4,14 @@ local Plugin = Rojo.Plugin
local Roact = require(Rojo.Roact) local Roact = require(Rojo.Roact)
local Config = require(Plugin.Config) local Config = require(Plugin.Config)
local Theme = require(Plugin.Theme)
local Theme = require(Plugin.Components.Theme)
local Panel = require(Plugin.Components.Panel) local Panel = require(Plugin.Components.Panel)
local FitList = require(Plugin.Components.FitList) local FitList = require(Plugin.Components.FitList)
local FitText = require(Plugin.Components.FitText) local FitText = require(Plugin.Components.FitText)
local FormButton = require(Plugin.Components.FormButton) local FormButton = require(Plugin.Components.FormButton)
local FormTextInput = require(Plugin.Components.FormTextInput) local FormTextInput = require(Plugin.Components.FormTextInput)
local PluginSettings = require(Plugin.Components.PluginSettings)
local e = Roact.createElement local e = Roact.createElement
@@ -25,137 +26,158 @@ end
function ConnectPanel:render() function ConnectPanel:render()
local startSession = self.props.startSession local startSession = self.props.startSession
local openSettings = self.props.openSettings
return e(Panel, nil, { return Theme.with(function(theme)
Layout = e("UIListLayout", { return PluginSettings.with(function(settings)
SortOrder = Enum.SortOrder.LayoutOrder, return e(Panel, nil, {
HorizontalAlignment = Enum.HorizontalAlignment.Center, Layout = e("UIListLayout", {
VerticalAlignment = Enum.VerticalAlignment.Center, SortOrder = Enum.SortOrder.LayoutOrder,
}), HorizontalAlignment = Enum.HorizontalAlignment.Center,
VerticalAlignment = Enum.VerticalAlignment.Center,
Inputs = e(FitList, {
containerProps = {
BackgroundTransparency = 1,
LayoutOrder = 1,
},
layoutProps = {
FillDirection = Enum.FillDirection.Horizontal,
Padding = UDim.new(0, 8),
},
paddingProps = {
PaddingTop = UDim.new(0, 20),
PaddingBottom = UDim.new(0, 10),
PaddingLeft = UDim.new(0, 24),
PaddingRight = UDim.new(0, 24),
},
}, {
Address = e(FitList, {
containerProps = {
LayoutOrder = 1,
BackgroundTransparency = 1,
},
layoutProps = {
Padding = UDim.new(0, 4),
},
}, {
Label = e(FitText, {
Kind = "TextLabel",
LayoutOrder = 1,
BackgroundTransparency = 1,
TextXAlignment = Enum.TextXAlignment.Left,
Font = Theme.TitleFont,
TextSize = 20,
Text = "Address",
TextColor3 = Theme.PrimaryColor,
}), }),
Input = e(FormTextInput, { Inputs = e(FitList, {
layoutOrder = 2, containerProps = {
width = UDim.new(0, 220), BackgroundTransparency = 1,
value = self.state.address, LayoutOrder = 1,
placeholderValue = Config.defaultHost, },
onValueChange = function(newValue) layoutProps = {
self:setState({ FillDirection = Enum.FillDirection.Horizontal,
address = newValue, Padding = UDim.new(0, 8),
}) },
end, paddingProps = {
}), PaddingTop = UDim.new(0, 20),
}), PaddingBottom = UDim.new(0, 10),
PaddingLeft = UDim.new(0, 24),
PaddingRight = UDim.new(0, 24),
},
}, {
Address = e(FitList, {
containerProps = {
LayoutOrder = 1,
BackgroundTransparency = 1,
},
layoutProps = {
Padding = UDim.new(0, 4),
},
}, {
Label = e(FitText, {
Kind = "TextLabel",
LayoutOrder = 1,
BackgroundTransparency = 1,
TextXAlignment = Enum.TextXAlignment.Left,
Font = theme.TitleFont,
TextSize = 20,
Text = "Address",
TextColor3 = theme.Text1,
}),
Port = e(FitList, { Input = e(FormTextInput, {
containerProps = { layoutOrder = 2,
LayoutOrder = 2, width = UDim.new(0, 220),
BackgroundTransparency = 1, value = self.state.address,
}, placeholderValue = Config.defaultHost,
layoutProps = { onValueChange = function(newValue)
Padding = UDim.new(0, 4), self:setState({
}, address = newValue,
}, { })
Label = e(FitText, { end,
Kind = "TextLabel", }),
LayoutOrder = 1, }),
BackgroundTransparency = 1,
TextXAlignment = Enum.TextXAlignment.Left, Port = e(FitList, {
Font = Theme.TitleFont, containerProps = {
TextSize = 20, LayoutOrder = 2,
Text = "Port", BackgroundTransparency = 1,
TextColor3 = Theme.PrimaryColor, },
layoutProps = {
Padding = UDim.new(0, 4),
},
}, {
Label = e(FitText, {
Kind = "TextLabel",
LayoutOrder = 1,
BackgroundTransparency = 1,
TextXAlignment = Enum.TextXAlignment.Left,
Font = theme.TitleFont,
TextSize = 20,
Text = "Port",
TextColor3 = theme.Text1,
}),
Input = e(FormTextInput, {
layoutOrder = 2,
width = UDim.new(0, 80),
value = self.state.port,
placeholderValue = Config.defaultPort,
onValueChange = function(newValue)
self:setState({
port = newValue,
})
end,
}),
}),
}), }),
Input = e(FormTextInput, { Buttons = e(FitList, {
layoutOrder = 2, fitAxes = "Y",
width = UDim.new(0, 80), containerProps = {
value = self.state.port, BackgroundTransparency = 1,
placeholderValue = Config.defaultPort, LayoutOrder = 2,
onValueChange = function(newValue) Size = UDim2.new(1, 0, 0, 0),
self:setState({ },
port = newValue, layoutProps = {
}) FillDirection = Enum.FillDirection.Horizontal,
end, HorizontalAlignment = Enum.HorizontalAlignment.Right,
Padding = UDim.new(0, 8),
},
paddingProps = {
PaddingTop = UDim.new(0, 0),
PaddingBottom = UDim.new(0, 20),
PaddingLeft = UDim.new(0, 24),
PaddingRight = UDim.new(0, 24),
},
}, {
e(FormButton, {
layoutOrder = 1,
text = "Settings",
secondary = true,
onClick = function()
if openSettings ~= nil then
openSettings()
end
end,
}),
e(FormButton, {
layoutOrder = 2,
text = "Connect",
onClick = function()
if startSession ~= nil then
local address = self.state.address
if address:len() == 0 then
address = Config.defaultHost
end
local port = self.state.port
if port:len() == 0 then
port = Config.defaultPort
end
local sessionOptions = {
openScriptsExternally = settings:get("openScriptsExternally"),
twoWaySync = settings:get("twoWaySync"),
}
startSession(address, port, sessionOptions)
end
end,
}),
}), }),
}), })
}), end)
end)
Buttons = e(FitList, {
fitAxes = "Y",
containerProps = {
BackgroundTransparency = 1,
LayoutOrder = 2,
Size = UDim2.new(1, 0, 0, 0),
},
layoutProps = {
FillDirection = Enum.FillDirection.Horizontal,
HorizontalAlignment = Enum.HorizontalAlignment.Right,
Padding = UDim.new(0, 8),
},
paddingProps = {
PaddingTop = UDim.new(0, 0),
PaddingBottom = UDim.new(0, 20),
PaddingLeft = UDim.new(0, 24),
PaddingRight = UDim.new(0, 24),
},
}, {
e(FormButton, {
layoutOrder = 2,
text = "Connect",
onClick = function()
if startSession ~= nil then
local address = self.state.address
if address:len() == 0 then
address = Config.defaultHost
end
local port = self.state.port
if port:len() == 0 then
port = Config.defaultPort
end
startSession(address, port)
end
end,
}),
}),
})
end end
return ConnectPanel return ConnectPanel

View File

@@ -2,8 +2,7 @@ local Roact = require(script:FindFirstAncestor("Rojo").Roact)
local Plugin = script:FindFirstAncestor("Plugin") local Plugin = script:FindFirstAncestor("Plugin")
local Theme = require(Plugin.Theme) local Theme = require(Plugin.Components.Theme)
local Panel = require(Plugin.Components.Panel) local Panel = require(Plugin.Components.Panel)
local FitText = require(Plugin.Components.FitText) local FitText = require(Plugin.Components.FitText)
@@ -12,23 +11,25 @@ local e = Roact.createElement
local ConnectingPanel = Roact.Component:extend("ConnectingPanel") local ConnectingPanel = Roact.Component:extend("ConnectingPanel")
function ConnectingPanel:render() function ConnectingPanel:render()
return e(Panel, nil, { return Theme.with(function(theme)
Layout = Roact.createElement("UIListLayout", { return e(Panel, nil, {
HorizontalAlignment = Enum.HorizontalAlignment.Center, Layout = Roact.createElement("UIListLayout", {
VerticalAlignment = Enum.VerticalAlignment.Center, HorizontalAlignment = Enum.HorizontalAlignment.Center,
SortOrder = Enum.SortOrder.LayoutOrder, VerticalAlignment = Enum.VerticalAlignment.Center,
Padding = UDim.new(0, 8), SortOrder = Enum.SortOrder.LayoutOrder,
}), Padding = UDim.new(0, 8),
}),
Text = e(FitText, { Text = e(FitText, {
Padding = Vector2.new(12, 6), Padding = Vector2.new(12, 6),
Font = Theme.ButtonFont, Font = theme.ButtonFont,
TextSize = 18, TextSize = 18,
Text = "Connecting...", Text = "Connecting...",
TextColor3 = Theme.PrimaryColor, TextColor3 = theme.Text1,
BackgroundTransparency = 1, BackgroundTransparency = 1,
}), }),
}) })
end)
end end
return ConnectingPanel return ConnectingPanel

View File

@@ -2,8 +2,7 @@ local Roact = require(script:FindFirstAncestor("Rojo").Roact)
local Plugin = script:FindFirstAncestor("Plugin") local Plugin = script:FindFirstAncestor("Plugin")
local Theme = require(Plugin.Theme) local Theme = require(Plugin.Components.Theme)
local Panel = require(Plugin.Components.Panel) local Panel = require(Plugin.Components.Panel)
local FitText = require(Plugin.Components.FitText) local FitText = require(Plugin.Components.FitText)
local FormButton = require(Plugin.Components.FormButton) local FormButton = require(Plugin.Components.FormButton)
@@ -15,32 +14,34 @@ local ConnectionActivePanel = Roact.Component:extend("ConnectionActivePanel")
function ConnectionActivePanel:render() function ConnectionActivePanel:render()
local stopSession = self.props.stopSession local stopSession = self.props.stopSession
return e(Panel, nil, { return Theme.with(function(theme)
Layout = Roact.createElement("UIListLayout", { return e(Panel, nil, {
HorizontalAlignment = Enum.HorizontalAlignment.Center, Layout = Roact.createElement("UIListLayout", {
VerticalAlignment = Enum.VerticalAlignment.Center, HorizontalAlignment = Enum.HorizontalAlignment.Center,
SortOrder = Enum.SortOrder.LayoutOrder, VerticalAlignment = Enum.VerticalAlignment.Center,
Padding = UDim.new(0, 8), SortOrder = Enum.SortOrder.LayoutOrder,
}), Padding = UDim.new(0, 8),
}),
Text = e(FitText, { Text = e(FitText, {
Padding = Vector2.new(12, 6), Padding = Vector2.new(12, 6),
Font = Theme.ButtonFont, Font = theme.ButtonFont,
TextSize = 18, TextSize = 18,
Text = "Connected to Live-Sync Server", Text = "Connected to Live-Sync Server",
TextColor3 = Theme.PrimaryColor, TextColor3 = theme.Text1,
BackgroundTransparency = 1, BackgroundTransparency = 1,
}), }),
DisconnectButton = e(FormButton, { DisconnectButton = e(FormButton, {
layoutOrder = 2, layoutOrder = 2,
text = "Disconnect", text = "Disconnect",
secondary = true, secondary = true,
onClick = function() onClick = function()
stopSession() stopSession()
end, end,
}), }),
}) })
end)
end end
return ConnectionActivePanel return ConnectionActivePanel

View File

@@ -2,8 +2,7 @@ local Roact = require(script:FindFirstAncestor("Rojo").Roact)
local Plugin = script:FindFirstAncestor("Plugin") local Plugin = script:FindFirstAncestor("Plugin")
local Theme = require(Plugin.Theme) local Theme = require(Plugin.Components.Theme)
local Panel = require(Plugin.Components.Panel) local Panel = require(Plugin.Components.Panel)
local FitText = require(Plugin.Components.FitText) local FitText = require(Plugin.Components.FitText)
local FitScrollingFrame = require(Plugin.Components.FitScrollingFrame) local FitScrollingFrame = require(Plugin.Components.FitScrollingFrame)
@@ -20,50 +19,52 @@ function ErrorPanel:render()
local errorMessage = self.props.errorMessage local errorMessage = self.props.errorMessage
local onDismiss = self.props.onDismiss local onDismiss = self.props.onDismiss
return e(Panel, nil, { return Theme.with(function(theme)
Layout = Roact.createElement("UIListLayout", { return e(Panel, nil, {
HorizontalAlignment = Enum.HorizontalAlignment.Center, Layout = Roact.createElement("UIListLayout", {
VerticalAlignment = Enum.VerticalAlignment.Center, HorizontalAlignment = Enum.HorizontalAlignment.Center,
SortOrder = Enum.SortOrder.LayoutOrder, VerticalAlignment = Enum.VerticalAlignment.Center,
Padding = UDim.new(0, 8), SortOrder = Enum.SortOrder.LayoutOrder,
}), Padding = UDim.new(0, 8),
ErrorContainer = e(FitScrollingFrame, {
containerProps = {
BackgroundTransparency = 1,
BorderSizePixel = 0,
Size = UDim2.new(1, -HOR_PADDING * 2, 1, -BUTTON_HEIGHT),
Position = UDim2.new(0, HOR_PADDING, 0, 0),
ScrollBarImageColor3 = Theme.PrimaryColor,
VerticalScrollBarInset = Enum.ScrollBarInset.ScrollBar,
ScrollingDirection = Enum.ScrollingDirection.Y,
},
}, {
Text = e(FitText, {
Size = UDim2.new(1, 0, 0, 0),
LayoutOrder = 1,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Top,
FitAxis = "Y",
Font = Theme.ButtonFont,
TextSize = 18,
Text = errorMessage,
TextWrap = true,
TextColor3 = Theme.PrimaryColor,
BackgroundTransparency = 1,
}), }),
}),
DismissButton = e(FormButton, { ErrorContainer = e(FitScrollingFrame, {
layoutOrder = 2, containerProps = {
text = "Dismiss", BackgroundTransparency = 1,
secondary = true, BorderSizePixel = 0,
onClick = function() Size = UDim2.new(1, -HOR_PADDING * 2, 1, -BUTTON_HEIGHT),
onDismiss() Position = UDim2.new(0, HOR_PADDING, 0, 0),
end, ScrollBarImageColor3 = theme.Text1,
}), VerticalScrollBarInset = Enum.ScrollBarInset.ScrollBar,
}) ScrollingDirection = Enum.ScrollingDirection.Y,
},
}, {
Text = e(FitText, {
Size = UDim2.new(1, 0, 0, 0),
LayoutOrder = 1,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Top,
FitAxis = "Y",
Font = theme.ButtonFont,
TextSize = 18,
Text = errorMessage,
TextWrap = true,
TextColor3 = theme.Text1,
BackgroundTransparency = 1,
}),
}),
DismissButton = e(FormButton, {
layoutOrder = 2,
text = "Dismiss",
secondary = true,
onClick = function()
onDismiss()
end,
}),
})
end)
end end
return ErrorPanel return ErrorPanel

View File

@@ -4,7 +4,7 @@ local Plugin = Rojo.Plugin
local Roact = require(Rojo.Roact) local Roact = require(Rojo.Roact)
local Assets = require(Plugin.Assets) local Assets = require(Plugin.Assets)
local Theme = require(Plugin.Theme) local Theme = require(Plugin.Components.Theme)
local FitList = require(Plugin.Components.FitList) local FitList = require(Plugin.Components.FitList)
local FitText = require(Plugin.Components.FitText) local FitText = require(Plugin.Components.FitText)
@@ -20,43 +20,45 @@ local function FormButton(props)
local textColor local textColor
local backgroundColor local backgroundColor
if props.secondary then return Theme.with(function(theme)
textColor = Theme.AccentColor if props.secondary then
backgroundColor = Theme.SecondaryColor textColor = theme.Brand1
else backgroundColor = theme.Background2
textColor = Theme.SecondaryColor else
backgroundColor = Theme.AccentColor textColor = theme.TextOnAccent
end backgroundColor = theme.Brand1
end
return e(FitList, { return e(FitList, {
containerKind = "ImageButton", containerKind = "ImageButton",
containerProps = { containerProps = {
LayoutOrder = layoutOrder, LayoutOrder = layoutOrder,
BackgroundTransparency = 1, BackgroundTransparency = 1,
Image = RoundBox.asset, Image = RoundBox.asset,
ImageRectOffset = RoundBox.offset, ImageRectOffset = RoundBox.offset,
ImageRectSize = RoundBox.size, ImageRectSize = RoundBox.size,
SliceCenter = RoundBox.center, SliceCenter = RoundBox.center,
ScaleType = Enum.ScaleType.Slice, ScaleType = Enum.ScaleType.Slice,
ImageColor3 = backgroundColor, ImageColor3 = backgroundColor,
[Roact.Event.Activated] = function() [Roact.Event.Activated] = function()
if onClick ~= nil then if onClick ~= nil then
onClick() onClick()
end end
end, end,
}, },
}, { }, {
Text = e(FitText, { Text = e(FitText, {
Kind = "TextLabel", Kind = "TextLabel",
Text = text, Text = text,
TextSize = 18, TextSize = 18,
TextColor3 = textColor, TextColor3 = textColor,
Font = Theme.ButtonFont, Font = theme.ButtonFont,
Padding = Vector2.new(16, 8), Padding = Vector2.new(16, 8),
BackgroundTransparency = 1, BackgroundTransparency = 1,
}), }),
}) })
end)
end end
return FormButton return FormButton

View File

@@ -4,7 +4,7 @@ local Plugin = Rojo.Plugin
local Roact = require(Rojo.Roact) local Roact = require(Rojo.Roact)
local Assets = require(Plugin.Assets) local Assets = require(Plugin.Assets)
local Theme = require(Plugin.Theme) local Theme = require(Plugin.Components.Theme)
local e = Roact.createElement local e = Roact.createElement
@@ -35,46 +35,48 @@ function FormTextInput:render()
shownPlaceholder = placeholderValue shownPlaceholder = placeholderValue
end end
return e("ImageLabel", { return Theme.with(function(theme)
LayoutOrder = layoutOrder, return e("ImageLabel", {
Image = RoundBox.asset, LayoutOrder = layoutOrder,
ImageRectOffset = RoundBox.offset, Image = RoundBox.asset,
ImageRectSize = RoundBox.size, ImageRectOffset = RoundBox.offset,
ScaleType = Enum.ScaleType.Slice, ImageRectSize = RoundBox.size,
SliceCenter = RoundBox.center, ScaleType = Enum.ScaleType.Slice,
ImageColor3 = Theme.SecondaryColor, SliceCenter = RoundBox.center,
Size = UDim2.new(width.Scale, width.Offset, 0, TEXT_SIZE + PADDING * 2), ImageColor3 = theme.Background2,
BackgroundTransparency = 1, Size = UDim2.new(width.Scale, width.Offset, 0, TEXT_SIZE + PADDING * 2),
}, {
InputInner = e("TextBox", {
BackgroundTransparency = 1, BackgroundTransparency = 1,
Size = UDim2.new(1, -PADDING * 2, 1, -PADDING * 2), }, {
Position = UDim2.new(0.5, 0, 0.5, 0), InputInner = e("TextBox", {
AnchorPoint = Vector2.new(0.5, 0.5), BackgroundTransparency = 1,
Font = Theme.InputFont, Size = UDim2.new(1, -PADDING * 2, 1, -PADDING * 2),
ClearTextOnFocus = false, Position = UDim2.new(0.5, 0, 0.5, 0),
TextXAlignment = Enum.TextXAlignment.Center, AnchorPoint = Vector2.new(0.5, 0.5),
TextSize = TEXT_SIZE, Font = theme.InputFont,
Text = value, ClearTextOnFocus = false,
PlaceholderText = shownPlaceholder, TextXAlignment = Enum.TextXAlignment.Center,
PlaceholderColor3 = Theme.LightTextColor, TextSize = TEXT_SIZE,
TextColor3 = Theme.PrimaryColor, Text = value,
PlaceholderText = shownPlaceholder,
PlaceholderColor3 = theme.Text2,
TextColor3 = theme.Text1,
[Roact.Change.Text] = function(rbx) [Roact.Change.Text] = function(rbx)
onValueChange(rbx.Text) onValueChange(rbx.Text)
end, end,
[Roact.Event.Focused] = function() [Roact.Event.Focused] = function()
self:setState({ self:setState({
focused = true, focused = true,
}) })
end, end,
[Roact.Event.FocusLost] = function() [Roact.Event.FocusLost] = function()
self:setState({ self:setState({
focused = false, focused = false,
}) })
end, end,
}), }),
}) })
end)
end end
return FormTextInput return FormTextInput

View File

@@ -3,6 +3,7 @@ local Roact = require(script:FindFirstAncestor("Rojo").Roact)
local Plugin = script:FindFirstAncestor("Plugin") local Plugin = script:FindFirstAncestor("Plugin")
local RojoFooter = require(Plugin.Components.RojoFooter) local RojoFooter = require(Plugin.Components.RojoFooter)
local Theme = require(Plugin.Components.Theme)
local e = Roact.createElement local e = Roact.createElement
@@ -13,22 +14,25 @@ function Panel:init()
end end
function Panel:render() function Panel:render()
return e("Frame", { return Theme.with(function(theme)
Size = UDim2.new(1, 0, 1, 0), return e("Frame", {
BackgroundTransparency = 1, Size = UDim2.new(1, 0, 1, 0),
}, { BackgroundColor3 = theme.Background1,
Layout = Roact.createElement("UIListLayout", { BorderSizePixel = 1,
HorizontalAlignment = Enum.HorizontalAlignment.Center, }, {
SortOrder = Enum.SortOrder.LayoutOrder, Layout = Roact.createElement("UIListLayout", {
}), HorizontalAlignment = Enum.HorizontalAlignment.Center,
SortOrder = Enum.SortOrder.LayoutOrder,
}),
Body = e("Frame", { Body = e("Frame", {
Size = UDim2.new(0, 360, 1, -32), Size = UDim2.new(0, 360, 1, -32),
BackgroundTransparency = 1, BackgroundTransparency = 1,
}, self.props[Roact.Children]), }, self.props[Roact.Children]),
Footer = e(RojoFooter), Footer = e(RojoFooter),
}) })
end)
end end
return Panel return Panel

View File

@@ -0,0 +1,121 @@
--[[
Persistent plugin settings that can be accessed via Roact context.
]]
local Rojo = script:FindFirstAncestor("Rojo")
local Roact = require(Rojo.Roact)
local defaultSettings = {
openScriptsExternally = false,
twoWaySync = false,
}
local Settings = {}
Settings.__index = Settings
function Settings.fromPlugin(plugin)
local values = {}
for name, defaultValue in pairs(defaultSettings) do
local savedValue = plugin:GetSetting("Rojo_" .. name)
if savedValue == nil then
plugin:SetSetting("Rojo_" .. name, defaultValue)
values[name] = defaultValue
else
values[name] = savedValue
end
end
return setmetatable({
__values = values,
__plugin = plugin,
__updateListeners = {},
}, Settings)
end
function Settings:get(name)
if defaultSettings[name] == nil then
error("Invalid setings name " .. tostring(name), 2)
end
return self.__values[name]
end
function Settings:set(name, value)
self.__plugin:SetSetting("Rojo_" .. name, value)
self.__values[name] = value
for callback in pairs(self.__updateListeners) do
callback(name, value)
end
end
function Settings:onUpdate(newCallback)
local newListeners = {}
for callback in pairs(self.__updateListeners) do
newListeners[callback] = true
end
newListeners[newCallback] = true
self.__updateListeners = newListeners
return function()
local newListeners = {}
for callback in pairs(self.__updateListeners) do
if callback ~= newCallback then
newListeners[callback] = true
end
end
self.__updateListeners = newListeners
end
end
local Context = Roact.createContext(nil)
local StudioProvider = Roact.Component:extend("StudioProvider")
function StudioProvider:init()
self.settings = Settings.fromPlugin(self.props.plugin)
end
function StudioProvider:render()
return Roact.createElement(Context.Provider, {
value = self.settings,
}, self.props[Roact.Children])
end
local InternalConsumer = Roact.Component:extend("InternalConsumer")
function InternalConsumer:render()
return self.props.render(self.props.settings)
end
function InternalConsumer:didMount()
self.disconnect = self.props.settings:onUpdate(function()
-- Trigger a dummy state update to update the settings consumer.
self:setState({})
end)
end
function InternalConsumer:willUnmount()
self.disconnect()
end
local function with(callback)
return Roact.createElement(Context.Consumer, {
render = function(settings)
return Roact.createElement(InternalConsumer, {
settings = settings,
render = callback,
})
end,
})
end
return {
StudioProvider = StudioProvider,
with = with,
}

View File

@@ -6,7 +6,7 @@ local Roact = require(Rojo.Roact)
local Config = require(Plugin.Config) local Config = require(Plugin.Config)
local Version = require(Plugin.Version) local Version = require(Plugin.Version)
local Assets = require(Plugin.Assets) local Assets = require(Plugin.Assets)
local Theme = require(Plugin.Theme) local Theme = require(Plugin.Components.Theme)
local e = Roact.createElement local e = Roact.createElement
@@ -18,50 +18,53 @@ function RojoFooter:init()
end end
function RojoFooter:render() function RojoFooter:render()
return e("Frame", { return Theme.with(function(theme)
LayoutOrder = 3, return e("Frame", {
Size = UDim2.new(1, 0, 0, 32), LayoutOrder = 3,
BackgroundColor3 = Theme.SecondaryColor, Size = UDim2.new(1, 0, 0, 32),
BorderSizePixel = 0, BackgroundColor3 = theme.Background2,
}, { BorderSizePixel = 0,
Padding = e("UIPadding", { ZIndex = 2,
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", { Padding = e("UIPadding", {
Image = Assets.Images.Logo, PaddingTop = UDim.new(0, 4),
Size = UDim2.new(0, 80, 0, 40), PaddingBottom = UDim.new(0, 4),
ScaleType = Enum.ScaleType.Fit, PaddingLeft = UDim.new(0, 8),
BackgroundTransparency = 1, PaddingRight = UDim.new(0, 8),
Position = UDim2.new(0, 0, 1, -10),
AnchorPoint = Vector2.new(0, 1),
}), }),
}),
Version = e("TextLabel", { LogoContainer = e("Frame", {
Position = UDim2.new(1, 0, 0, 0), BackgroundTransparency = 1,
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) Size = UDim2.new(0, 0, 0, 32),
self.setFooterVersionSize(rbx.AbsoluteSize) }, {
end, Logo = e("ImageLabel", {
}), Image = Assets.Images.Logo,
}) Size = UDim2.new(0, 80, 0, 40),
ScaleType = Enum.ScaleType.Fit,
BackgroundTransparency = 1,
Position = UDim2.new(0, 0, 1, -10),
AnchorPoint = Vector2.new(0, 1),
}),
}),
Version = e("TextLabel", {
Position = UDim2.new(1, 0, 0, 0),
Size = UDim2.new(0, 0, 1, 0),
AnchorPoint = Vector2.new(1, 0),
Font = theme.TitleFont,
TextSize = 18,
Text = Version.display(Config.version),
TextXAlignment = Enum.TextXAlignment.Right,
TextColor3 = theme.Text2,
BackgroundTransparency = 1,
[Roact.Change.AbsoluteSize] = function(rbx)
self.setFooterVersionSize(rbx.AbsoluteSize)
end,
}),
})
end)
end end
return RojoFooter return RojoFooter

View File

@@ -0,0 +1,119 @@
local Roact = require(script:FindFirstAncestor("Rojo").Roact)
local Plugin = script:FindFirstAncestor("Plugin")
local Checkbox = require(Plugin.Components.Checkbox)
local FitList = require(Plugin.Components.FitList)
local FitText = require(Plugin.Components.FitText)
local FormButton = require(Plugin.Components.FormButton)
local Panel = require(Plugin.Components.Panel)
local PluginSettings = require(Plugin.Components.PluginSettings)
local Theme = require(Plugin.Components.Theme)
local e = Roact.createElement
local SettingsPanel = Roact.Component:extend("SettingsPanel")
function SettingsPanel:render()
local back = self.props.back
return Theme.with(function(theme)
return PluginSettings.with(function(settings)
return e(Panel, nil, {
Layout = Roact.createElement("UIListLayout", {
HorizontalAlignment = Enum.HorizontalAlignment.Center,
VerticalAlignment = Enum.VerticalAlignment.Center,
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 16),
}),
OpenScriptsExternally = e(FitList, {
containerProps = {
LayoutOrder = 1,
BackgroundTransparency = 1,
},
layoutProps = {
Padding = UDim.new(0, 4),
FillDirection = Enum.FillDirection.Horizontal,
HorizontalAlignment = Enum.HorizontalAlignment.Left,
VerticalAlignment = Enum.VerticalAlignment.Center,
},
}, {
Label = e(FitText, {
Kind = "TextLabel",
LayoutOrder = 1,
BackgroundTransparency = 1,
TextXAlignment = Enum.TextXAlignment.Left,
Font = theme.MainFont,
TextSize = 16,
Text = "Open Scripts Externally",
TextColor3 = theme.Text1,
}),
Padding = e("Frame", {
Size = UDim2.new(0, 8, 0, 0),
BackgroundTransparency = 1,
LayoutOrder = 2,
}),
Input = e(Checkbox, {
layoutOrder = 3,
checked = settings:get("openScriptsExternally"),
onChange = function(newValue)
settings:set("openScriptsExternally", not settings:get("openScriptsExternally"))
end,
}),
}),
TwoWaySync = e(FitList, {
containerProps = {
LayoutOrder = 2,
BackgroundTransparency = 1,
},
layoutProps = {
Padding = UDim.new(0, 4),
FillDirection = Enum.FillDirection.Horizontal,
HorizontalAlignment = Enum.HorizontalAlignment.Left,
VerticalAlignment = Enum.VerticalAlignment.Center,
},
}, {
Label = e(FitText, {
Kind = "TextLabel",
LayoutOrder = 1,
BackgroundTransparency = 1,
TextXAlignment = Enum.TextXAlignment.Left,
Font = theme.MainFont,
TextSize = 16,
Text = "Two-Way Sync (Experimental!)",
TextColor3 = theme.Text1,
}),
Padding = e("Frame", {
Size = UDim2.new(0, 8, 0, 0),
BackgroundTransparency = 1,
LayoutOrder = 2,
}),
Input = e(Checkbox, {
layoutOrder = 3,
checked = settings:get("twoWaySync"),
onChange = function(newValue)
settings:set("twoWaySync", not settings:get("twoWaySync"))
end,
}),
}),
BackButton = e(FormButton, {
layoutOrder = 4,
text = "Okay",
secondary = true,
onClick = function()
back()
end,
}),
})
end)
end)
end
return SettingsPanel

View File

@@ -0,0 +1,104 @@
--[[
Theming system taking advantage of Roact's new context API.
Doesn't use colors provided by Studio and instead just branches on theme
name. This isn't exactly best practice.
]]
local Studio = settings():GetService("Studio")
local Rojo = script:FindFirstAncestor("Rojo")
local Roact = require(Rojo.Roact)
local Log = require(Rojo.Log)
local strict = require(script.Parent.Parent.strict)
local lightTheme = strict("Theme", {
ButtonFont = Enum.Font.GothamSemibold,
InputFont = Enum.Font.Code,
TitleFont = Enum.Font.GothamBold,
MainFont = Enum.Font.Gotham,
Brand1 = Color3.fromRGB(225, 56, 53),
Text1 = Color3.fromRGB(64, 64, 64),
Text2 = Color3.fromRGB(160, 160, 160),
TextOnAccent = Color3.fromRGB(235, 235, 235),
Background1 = Color3.fromRGB(255, 255, 255),
Background2 = Color3.fromRGB(235, 235, 235),
})
local darkTheme = strict("Theme", {
ButtonFont = Enum.Font.GothamSemibold,
InputFont = Enum.Font.Code,
TitleFont = Enum.Font.GothamBold,
MainFont = Enum.Font.Gotham,
Brand1 = Color3.fromRGB(225, 56, 53),
Text1 = Color3.fromRGB(235, 235, 235),
Text2 = Color3.fromRGB(200, 200, 200),
TextOnAccent = Color3.fromRGB(235, 235, 235),
Background1 = Color3.fromRGB(48, 48, 48),
Background2 = Color3.fromRGB(64, 64, 64),
})
local Context = Roact.createContext(lightTheme)
local StudioProvider = Roact.Component:extend("StudioProvider")
-- Pull the current theme from Roblox Studio and update state with it.
function StudioProvider:updateTheme()
local studioTheme = Studio.Theme
if studioTheme.Name == "Light" then
self:setState({
theme = lightTheme,
})
elseif studioTheme.Name == "Dark" then
self:setState({
theme = darkTheme,
})
else
Log.warn("Unexpected theme '{}'' -- falling back to light theme!", studioTheme.Name)
self:setState({
theme = lightTheme,
})
end
end
function StudioProvider:init()
self:updateTheme()
end
function StudioProvider:render()
return Roact.createElement(Context.Provider, {
value = self.state.theme,
}, self.props[Roact.Children])
end
function StudioProvider:didMount()
self.connection = Studio.ThemeChanged:Connect(function()
self:updateTheme()
end)
end
function StudioProvider:willUnmount()
self.connection:Disconnect()
end
local function with(callback)
return Roact.createElement(Context.Consumer, {
render = callback,
})
end
return {
StudioProvider = StudioProvider,
Consumer = Context.Consumer,
with = with,
}

View File

@@ -5,7 +5,7 @@ local isDevBuild = script.Parent.Parent:FindFirstChild("ROJO_DEV_BUILD") ~= nil
return strict("Config", { return strict("Config", {
isDevBuild = isDevBuild, isDevBuild = isDevBuild,
codename = "Epiphany", codename = "Epiphany",
version = {0, 6, 0, "-alpha.2"}, version = {0, 6, 0, "-alpha.3"},
expectedServerVersionString = "0.6.0 or newer", expectedServerVersionString = "0.6.0 or newer",
protocolVersion = 3, protocolVersion = 3,
defaultHost = "localhost", defaultHost = "localhost",

View File

@@ -25,14 +25,6 @@ local VALUES = {
[Environment.Test] = true, [Environment.Test] = true,
}, },
}, },
UnstableTwoWaySync = {
type = "BoolValue",
values = {
[Environment.User] = false,
[Environment.Dev] = false,
[Environment.Test] = false,
},
},
} }
local CONTAINER_NAME = "RojoDevSettings" .. Config.codename local CONTAINER_NAME = "RojoDevSettings" .. Config.codename
@@ -140,10 +132,6 @@ function DevSettings:shouldTypecheck()
return getValue("TypecheckingEnabled") return getValue("TypecheckingEnabled")
end end
function DevSettings:twoWaySyncEnabled()
return getValue("UnstableTwoWaySync")
end
function _G.ROJO_DEV_CREATE() function _G.ROJO_DEV_CREATE()
DevSettings:createDevSettings() DevSettings:createDevSettings()
end end

View File

@@ -1,3 +1,5 @@
local StudioService = game:GetService("StudioService")
local Log = require(script.Parent.Parent.Log) local Log = require(script.Parent.Parent.Log)
local Fmt = require(script.Parent.Parent.Fmt) local Fmt = require(script.Parent.Parent.Fmt)
local t = require(script.Parent.Parent.t) local t = require(script.Parent.Parent.t)
@@ -43,6 +45,8 @@ ServeSession.Status = Status
local validateServeOptions = t.strictInterface({ local validateServeOptions = t.strictInterface({
apiContext = t.table, apiContext = t.table,
openScriptsExternally = t.boolean,
twoWaySync = t.boolean,
}) })
function ServeSession.new(options) function ServeSession.new(options)
@@ -57,12 +61,28 @@ function ServeSession.new(options)
local instanceMap = InstanceMap.new(onInstanceChanged) local instanceMap = InstanceMap.new(onInstanceChanged)
local reconciler = Reconciler.new(instanceMap) local reconciler = Reconciler.new(instanceMap)
local connections = {}
local connection = StudioService
:GetPropertyChangedSignal("ActiveScript")
:Connect(function()
local activeScript = StudioService.ActiveScript
if activeScript ~= nil then
self:__onActiveScriptChanged(activeScript)
end
end)
table.insert(connections, connection)
self = { self = {
__status = Status.NotStarted, __status = Status.NotStarted,
__apiContext = options.apiContext, __apiContext = options.apiContext,
__openScriptsExternally = options.openScriptsExternally,
__twoWaySync = options.twoWaySync,
__reconciler = reconciler, __reconciler = reconciler,
__instanceMap = instanceMap, __instanceMap = instanceMap,
__statusChangedCallback = nil, __statusChangedCallback = nil,
__connections = connections,
} }
setmetatable(self, ServeSession) setmetatable(self, ServeSession)
@@ -108,8 +128,39 @@ function ServeSession:stop()
self:__stopInternal() self:__stopInternal()
end end
function ServeSession:__onActiveScriptChanged(activeScript)
if not self.__openScriptsExternally then
Log.trace("Not opening script {} because feature not enabled.", activeScript)
return
end
if self.__status ~= Status.Connected then
Log.trace("Not opening script {} because session is not connected.", activeScript)
return
end
local scriptId = self.__instanceMap.fromInstances[activeScript]
if scriptId == nil then
Log.trace("Not opening script {} because it is not known by Rojo.", activeScript)
return
end
Log.debug("Trying to open script {} externally...", activeScript)
-- Force-close the script inside Studio
local existingParent = activeScript.Parent
activeScript.Parent = nil
activeScript.Parent = existingParent
-- Notify the Rojo server to open this script
self.__apiContext:open(scriptId)
end
function ServeSession:__onInstanceChanged(instance, propertyName) function ServeSession:__onInstanceChanged(instance, propertyName)
if not DevSettings:twoWaySyncEnabled() then if not self.__twoWaySync then
return return
end end
@@ -200,6 +251,11 @@ function ServeSession:__stopInternal(err)
self:__setStatus(Status.Disconnected, err) self:__setStatus(Status.Disconnected, err)
self.__apiContext:disconnect() self.__apiContext:disconnect()
self.__instanceMap:stop() self.__instanceMap:stop()
for _, connection in ipairs(self.__connections) do
connection:Disconnect()
end
self.__connections = {}
end end
function ServeSession:__setStatus(status, detail) function ServeSession:__setStatus(status, detail)

View File

@@ -1,14 +0,0 @@
local strict = require(script.Parent.strict)
return strict("Theme", {
ButtonFont = Enum.Font.GothamSemibold,
InputFont = Enum.Font.Code,
TitleFont = Enum.Font.GothamBold,
MainFont = Enum.Font.Gotham,
AccentColor = Color3.fromRGB(225, 56, 53),
AccentLightColor = Color3.fromRGB(255, 146, 145),
PrimaryColor = Color3.fromRGB(64, 64, 64),
SecondaryColor = Color3.fromRGB(235, 235, 235),
LightTextColor = Color3.fromRGB(160, 160, 160),
})

View File

@@ -14,12 +14,20 @@ local Roact = require(script.Parent.Roact)
local Config = require(script.Config) local Config = require(script.Config)
local App = require(script.Components.App) local App = require(script.Components.App)
local Theme = require(script.Components.Theme)
local PluginSettings = require(script.Components.PluginSettings)
local app = Roact.createElement(App, { local app = Roact.createElement(Theme.StudioProvider, nil, {
plugin = plugin, Roact.createElement(PluginSettings.StudioProvider, {
plugin = plugin,
}, {
RojoUI = Roact.createElement(App, {
plugin = plugin,
}),
})
}) })
local tree = Roact.mount(app, game:GetService("CoreGui"), "Rojo UI") local tree = Roact.mount(app, nil, "Rojo UI")
plugin.Unloading:Connect(function() plugin.Unloading:Connect(function()
Roact.unmount(tree) Roact.unmount(tree)

View File

@@ -11,7 +11,7 @@ default = []
unstable_glob_ignore_paths = [] unstable_glob_ignore_paths = []
[dependencies] [dependencies]
env_logger = "0.6.2" env_logger = "0.7.1"
insta = { version = "0.13.1", features = ["redactions"] } insta = { version = "0.13.1", features = ["redactions"] }
log = "0.4.8" log = "0.4.8"
paste = "0.1.5" paste = "0.1.5"

View File

@@ -1,65 +1,94 @@
use std::{error::Error, panic, process}; use std::{env, error::Error, panic, process};
use log::error; use backtrace::Backtrace;
use structopt::StructOpt; use structopt::StructOpt;
use librojo::cli::{self, Options, Subcommand}; use librojo::cli::{self, GlobalOptions, Options, Subcommand};
fn main() { fn run(global: GlobalOptions, subcommand: Subcommand) -> Result<(), Box<dyn Error>> {
let options = Options::from_args();
{
let log_filter = match options.verbosity {
0 => "warn",
1 => "warn,librojo=info",
2 => "warn,librojo=trace",
_ => "trace",
};
let log_env = env_logger::Env::default().default_filter_or(log_filter);
env_logger::Builder::from_env(log_env)
.format_timestamp(None)
.init();
}
let panic_result = panic::catch_unwind(|| {
if let Err(err) = run(options.subcommand) {
log::error!("{}", err);
process::exit(1);
}
});
if let Err(error) = panic_result {
let message = match error.downcast_ref::<&str>() {
Some(message) => message.to_string(),
None => match error.downcast_ref::<String>() {
Some(message) => message.clone(),
None => "<no message>".to_string(),
},
};
show_crash_message(&message);
process::exit(1);
}
}
fn run(subcommand: Subcommand) -> Result<(), Box<dyn Error>> {
match subcommand { match subcommand {
Subcommand::Init(init_options) => cli::init(init_options)?, Subcommand::Init(init_options) => cli::init(init_options)?,
Subcommand::Serve(serve_options) => cli::serve(serve_options)?, Subcommand::Serve(serve_options) => cli::serve(global, serve_options)?,
Subcommand::Build(build_options) => cli::build(build_options)?, Subcommand::Build(build_options) => cli::build(build_options)?,
Subcommand::Upload(upload_options) => cli::upload(upload_options)?, Subcommand::Upload(upload_options) => cli::upload(upload_options)?,
Subcommand::Doc => cli::doc()?,
} }
Ok(()) Ok(())
} }
fn show_crash_message(message: &str) { fn main() {
error!("Rojo crashed!"); panic::set_hook(Box::new(|panic_info| {
error!("This is a bug in Rojo."); // PanicInfo's payload is usually a &'static str or String.
error!(""); // See: https://doc.rust-lang.org/beta/std/panic/struct.PanicInfo.html#method.payload
error!("Please consider filing a bug: https://github.com/rojo-rbx/rojo/issues"); let message = match panic_info.payload().downcast_ref::<&str>() {
error!(""); Some(message) => message.to_string(),
error!("Details: {}", message); None => match panic_info.payload().downcast_ref::<String>() {
Some(message) => message.clone(),
None => "<no message>".to_string(),
},
};
log::error!("Rojo crashed!");
log::error!("This is probably a Rojo bug.");
log::error!("");
log::error!(
"Please consider filing an issue: {}/issues",
env!("CARGO_PKG_REPOSITORY")
);
log::error!("");
log::error!("Details: {}", message);
if let Some(location) = panic_info.location() {
log::error!("in file {} on line {}", location.file(), location.line());
}
// When using the backtrace crate, we need to check the RUST_BACKTRACE
// environment variable ourselves. Once we switch to the (currently
// unstable) std::backtrace module, we won't need to do this anymore.
let should_backtrace = env::var("RUST_BACKTRACE")
.map(|var| var == "1")
.unwrap_or(false);
if should_backtrace {
eprintln!("{:?}", Backtrace::new());
} else {
eprintln!(
"note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace."
);
}
process::exit(1);
}));
let options = Options::from_args();
let log_filter = match options.global.verbosity {
0 => "info",
1 => "info,librojo=debug",
2 => "info,librojo=trace",
_ => "trace",
};
let log_env = env_logger::Env::default().default_filter_or(log_filter);
env_logger::Builder::from_env(log_env)
.format_module_path(false)
.format_timestamp(None)
// Indent following lines equal to the log level label, like `[ERROR] `
.format_indent(Some(8))
.write_style(options.global.color.into())
.init();
if let Err(err) = run(options.global, options.subcommand) {
log::error!("{}", err);
let mut current_err: &dyn Error = &*err;
while let Some(source) = current_err.source() {
log::error!(" caused by {}", source);
current_err = &*source;
}
process::exit(1);
}
} }

View File

@@ -5,15 +5,16 @@ use std::{
use crossbeam_channel::{select, Receiver, RecvError, Sender}; use crossbeam_channel::{select, Receiver, RecvError, Sender};
use jod_thread::JoinHandle; use jod_thread::JoinHandle;
use memofs::{IoResultExt, Vfs, VfsEvent};
use rbx_dom_weak::{RbxId, RbxValue}; use rbx_dom_weak::{RbxId, RbxValue};
use crate::{ use crate::{
error::ErrorDisplay,
message_queue::MessageQueue, message_queue::MessageQueue,
snapshot::{ snapshot::{
apply_patch_set, compute_patch_set, AppliedPatchSet, InstigatingSource, PatchSet, RojoTree, apply_patch_set, compute_patch_set, AppliedPatchSet, InstigatingSource, PatchSet, RojoTree,
}, },
snapshot_middleware::{snapshot_from_vfs, snapshot_project_node}, snapshot_middleware::{snapshot_from_vfs, snapshot_project_node},
vfs::{FsResultExt, Vfs, VfsEvent, VfsFetcher},
}; };
/// Owns the connection between Rojo's VFS and its DOM by holding onto another /// Owns the connection between Rojo's VFS and its DOM by holding onto another
@@ -43,14 +44,14 @@ pub struct ChangeProcessor {
impl ChangeProcessor { impl ChangeProcessor {
/// Spin up the ChangeProcessor, connecting it to the given tree, VFS, and /// Spin up the ChangeProcessor, connecting it to the given tree, VFS, and
/// outbound message queue. /// outbound message queue.
pub fn start<F: VfsFetcher + Send + Sync + 'static>( pub fn start(
tree: Arc<Mutex<RojoTree>>, tree: Arc<Mutex<RojoTree>>,
vfs: Arc<Vfs<F>>, vfs: Arc<Vfs>,
message_queue: Arc<MessageQueue<AppliedPatchSet>>, message_queue: Arc<MessageQueue<AppliedPatchSet>>,
tree_mutation_receiver: Receiver<PatchSet>, tree_mutation_receiver: Receiver<PatchSet>,
) -> Self { ) -> Self {
let (shutdown_sender, shutdown_receiver) = crossbeam_channel::bounded(1); let (shutdown_sender, shutdown_receiver) = crossbeam_channel::bounded(1);
let vfs_receiver = vfs.change_receiver(); let vfs_receiver = vfs.event_receiver();
let task = JobThreadContext { let task = JobThreadContext {
tree, tree,
vfs, vfs,
@@ -107,25 +108,25 @@ impl Drop for ChangeProcessor {
} }
/// Contains all of the state needed to synchronize the DOM and VFS. /// Contains all of the state needed to synchronize the DOM and VFS.
struct JobThreadContext<F> { struct JobThreadContext {
/// A handle to the DOM we're managing. /// A handle to the DOM we're managing.
tree: Arc<Mutex<RojoTree>>, tree: Arc<Mutex<RojoTree>>,
/// A handle to the VFS we're managing. /// A handle to the VFS we're managing.
vfs: Arc<Vfs<F>>, vfs: Arc<Vfs>,
/// Whenever changes are applied to the DOM, we should push those changes /// Whenever changes are applied to the DOM, we should push those changes
/// into this message queue to inform any connected clients. /// into this message queue to inform any connected clients.
message_queue: Arc<MessageQueue<AppliedPatchSet>>, message_queue: Arc<MessageQueue<AppliedPatchSet>>,
} }
impl<F: VfsFetcher> JobThreadContext<F> { impl JobThreadContext {
fn handle_vfs_event(&self, event: VfsEvent) { fn handle_vfs_event(&self, event: VfsEvent) {
log::trace!("Vfs event: {:?}", event); log::trace!("Vfs event: {:?}", event);
// Update the VFS immediately with the event. // Update the VFS immediately with the event.
self.vfs self.vfs
.commit_change(&event) .commit_event(&event)
.expect("Error applying VFS change"); .expect("Error applying VFS change");
// For a given VFS event, we might have many changes to different parts // For a given VFS event, we might have many changes to different parts
@@ -135,7 +136,7 @@ impl<F: VfsFetcher> JobThreadContext<F> {
let mut applied_patches = Vec::new(); let mut applied_patches = Vec::new();
match event { match event {
VfsEvent::Created(path) | VfsEvent::Modified(path) | VfsEvent::Removed(path) => { VfsEvent::Create(path) | VfsEvent::Write(path) | VfsEvent::Remove(path) => {
// Find the nearest ancestor to this path that has // Find the nearest ancestor to this path that has
// associated instances in the tree. This helps make sure // associated instances in the tree. This helps make sure
// that we handle additions correctly, especially if we // that we handle additions correctly, especially if we
@@ -164,6 +165,7 @@ impl<F: VfsFetcher> JobThreadContext<F> {
} }
} }
} }
_ => log::warn!("Unhandled VFS event: {:?}", event),
} }
applied_patches applied_patches
@@ -262,11 +264,7 @@ impl<F: VfsFetcher> JobThreadContext<F> {
} }
} }
fn compute_and_apply_changes<F: VfsFetcher>( fn compute_and_apply_changes(tree: &mut RojoTree, vfs: &Vfs, id: RbxId) -> Option<AppliedPatchSet> {
tree: &mut RojoTree,
vfs: &Vfs<F>,
id: RbxId,
) -> Option<AppliedPatchSet> {
let metadata = tree let metadata = tree
.get_metadata(id) .get_metadata(id)
.expect("metadata missing for instance present in tree"); .expect("metadata missing for instance present in tree");
@@ -288,21 +286,24 @@ fn compute_and_apply_changes<F: VfsFetcher>(
// file/folder in the first place. // file/folder in the first place.
let applied_patch_set = match instigating_source { let applied_patch_set = match instigating_source {
InstigatingSource::Path(path) => { InstigatingSource::Path(path) => {
let maybe_entry = vfs let maybe_meta = vfs.metadata(path).with_not_found().unwrap();
.get(path)
.with_not_found()
.expect("unexpected VFS error");
match maybe_entry { match maybe_meta {
Some(entry) => { Some(_meta) => {
// Our instance was previously created from a path and // Our instance was previously created from a path and
// that path still exists. We can generate a snapshot // that path still exists. We can generate a snapshot
// starting at that path and use it as the source for // starting at that path and use it as the source for
// our patch. // our patch.
let snapshot = snapshot_from_vfs(&metadata.context, &vfs, &entry) let snapshot = match snapshot_from_vfs(&metadata.context, &vfs, &path) {
.expect("snapshot failed") Ok(maybe_snapshot) => {
.expect("snapshot did not return an instance"); maybe_snapshot.expect("snapshot did not return an instance")
}
Err(err) => {
log::error!("Snapshot error: {}", ErrorDisplay(err));
return None;
}
};
let patch_set = compute_patch_set(&snapshot, &tree, id); let patch_set = compute_patch_set(&snapshot, &tree, id);
apply_patch_set(tree, patch_set) apply_patch_set(tree, patch_set)
@@ -326,15 +327,21 @@ fn compute_and_apply_changes<F: VfsFetcher>(
// there might be information associated with our instance from // there might be information associated with our instance from
// the project file, we snapshot the entire project node again. // the project file, we snapshot the entire project node again.
let snapshot = snapshot_project_node( let snapshot_result = snapshot_project_node(
&metadata.context, &metadata.context,
&project_path, &project_path,
instance_name, instance_name,
project_node, project_node,
&vfs, &vfs,
) );
.expect("snapshot failed")
.expect("snapshot did not return an instance"); let snapshot = match snapshot_result {
Ok(maybe_snapshot) => maybe_snapshot.expect("snapshot did not return an instance"),
Err(err) => {
log::error!("Snapshot error: {}", ErrorDisplay(err));
return None;
}
};
let patch_set = compute_patch_set(&snapshot, &tree, id); let patch_set = compute_patch_set(&snapshot, &tree, id);
apply_patch_set(tree, patch_set) apply_patch_set(tree, patch_set)

View File

@@ -1,16 +1,13 @@
use std::{ use std::{
fs::File, fs::File,
io::{self, BufWriter, Write}, io::{BufWriter, Write},
}; };
use snafu::{ResultExt, Snafu}; use memofs::Vfs;
use thiserror::Error;
use tokio::runtime::Runtime;
use crate::{ use crate::{cli::BuildCommand, serve_session::ServeSession, snapshot::RojoTree};
cli::BuildCommand,
common_setup,
project::ProjectError,
vfs::{RealFetcher, Vfs, WatchMode},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OutputKind { enum OutputKind {
@@ -32,58 +29,53 @@ fn detect_output_kind(options: &BuildCommand) -> Option<OutputKind> {
} }
} }
#[derive(Debug, Snafu)] #[derive(Debug, Error)]
pub struct BuildError(Error);
#[derive(Debug, Snafu)]
enum Error { enum Error {
#[snafu(display("Could not detect what kind of file to create"))] #[error("Could not detect what kind of file to build. Expected output file to end in .rbxl, .rbxlx, .rbxm, or .rbxmx.")]
UnknownOutputKind, UnknownOutputKind,
#[snafu(display("{}", source))]
Io { source: io::Error },
#[snafu(display("{}", source))]
XmlModelEncode { source: rbx_xml::EncodeError },
#[snafu(display("Binary model error: {:?}", source))]
BinaryModelEncode {
#[snafu(source(false))]
source: rbx_binary::EncodeError,
},
#[snafu(display("{}", source))]
Project { source: ProjectError },
}
impl From<rbx_binary::EncodeError> for Error {
fn from(source: rbx_binary::EncodeError) -> Self {
Error::BinaryModelEncode { source }
}
} }
fn xml_encode_config() -> rbx_xml::EncodeOptions { fn xml_encode_config() -> rbx_xml::EncodeOptions {
rbx_xml::EncodeOptions::new().property_behavior(rbx_xml::EncodePropertyBehavior::WriteUnknown) rbx_xml::EncodeOptions::new().property_behavior(rbx_xml::EncodePropertyBehavior::WriteUnknown)
} }
pub fn build(options: BuildCommand) -> Result<(), BuildError> { pub fn build(options: BuildCommand) -> Result<(), anyhow::Error> {
Ok(build_inner(options)?) log::trace!("Constructing in-memory filesystem");
let vfs = Vfs::new_default();
let session = ServeSession::new(vfs, &options.absolute_project());
let mut cursor = session.message_queue().cursor();
{
let tree = session.tree();
write_model(&tree, &options)?;
}
if options.watch {
let mut rt = Runtime::new().unwrap();
loop {
let receiver = session.message_queue().subscribe(cursor);
let (new_cursor, _patch_set) = rt.block_on(receiver).unwrap();
cursor = new_cursor;
let tree = session.tree();
write_model(&tree, &options)?;
}
}
Ok(())
} }
fn build_inner(options: BuildCommand) -> Result<(), Error> { fn write_model(tree: &RojoTree, options: &BuildCommand) -> Result<(), anyhow::Error> {
let output_kind = detect_output_kind(&options).ok_or(Error::UnknownOutputKind)?; let output_kind = detect_output_kind(&options).ok_or(Error::UnknownOutputKind)?;
log::debug!("Hoping to generate file of type {:?}", output_kind); log::debug!("Hoping to generate file of type {:?}", output_kind);
log::trace!("Constructing in-memory filesystem");
let vfs = Vfs::new(RealFetcher::new(WatchMode::Disabled));
let (_maybe_project, tree) = common_setup::start(&options.absolute_project(), &vfs);
let root_id = tree.get_root_id(); let root_id = tree.get_root_id();
log::trace!("Opening output file for write"); log::trace!("Opening output file for write");
let file = File::create(&options.output)?;
let file = File::create(&options.output).context(Io)?;
let mut file = BufWriter::new(file); let mut file = BufWriter::new(file);
match output_kind { match output_kind {
@@ -91,8 +83,7 @@ fn build_inner(options: BuildCommand) -> Result<(), Error> {
// Model files include the root instance of the tree and all its // Model files include the root instance of the tree and all its
// descendants. // descendants.
rbx_xml::to_writer(&mut file, tree.inner(), &[root_id], xml_encode_config()) rbx_xml::to_writer(&mut file, tree.inner(), &[root_id], xml_encode_config())?;
.context(XmlModelEncode)?;
} }
OutputKind::Rbxlx => { OutputKind::Rbxlx => {
// Place files don't contain an entry for the DataModel, but our // Place files don't contain an entry for the DataModel, but our
@@ -101,8 +92,7 @@ fn build_inner(options: BuildCommand) -> Result<(), Error> {
let root_instance = tree.get_instance(root_id).unwrap(); let root_instance = tree.get_instance(root_id).unwrap();
let top_level_ids = root_instance.children(); let top_level_ids = root_instance.children();
rbx_xml::to_writer(&mut file, tree.inner(), top_level_ids, xml_encode_config()) rbx_xml::to_writer(&mut file, tree.inner(), top_level_ids, xml_encode_config())?;
.context(XmlModelEncode)?;
} }
OutputKind::Rbxm => { OutputKind::Rbxm => {
rbx_binary::encode(tree.inner(), &[root_id], &mut file)?; rbx_binary::encode(tree.inner(), &[root_id], &mut file)?;
@@ -119,9 +109,14 @@ fn build_inner(options: BuildCommand) -> Result<(), Error> {
} }
} }
file.flush().context(Io)?; file.flush()?;
log::trace!("Done!"); let filename = options
.output
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("<invalid utf-8>");
log::info!("Built project to {}", filename);
Ok(()) Ok(())
} }

4
src/cli/doc.rs Normal file
View File

@@ -0,0 +1,4 @@
pub fn doc() -> Result<(), anyhow::Error> {
opener::open("https://rojo.space/docs")?;
Ok(())
}

View File

@@ -1,17 +1,213 @@
use snafu::Snafu; use std::{
fs::{self, OpenOptions},
io::{self, Write},
path::Path,
process::{Command, Stdio},
};
use crate::cli::InitCommand; use thiserror::Error;
#[derive(Debug, Snafu)] use crate::cli::{InitCommand, InitKind};
pub struct InitError(Error);
#[derive(Debug, Snafu)] static MODEL_PROJECT: &str =
enum Error {} include_str!("../../assets/default-model-project/default.project.json");
static MODEL_README: &str = include_str!("../../assets/default-model-project/README.md");
static MODEL_INIT: &str = include_str!("../../assets/default-model-project/src-init.lua");
static MODEL_GIT_IGNORE: &str = include_str!("../../assets/default-model-project/gitignore.txt");
pub fn init(options: InitCommand) -> Result<(), InitError> { static PLACE_PROJECT: &str =
Ok(init_inner(options)?) include_str!("../../assets/default-place-project/default.project.json");
static PLACE_README: &str = include_str!("../../assets/default-place-project/README.md");
static PLACE_GIT_IGNORE: &str = include_str!("../../assets/default-place-project/gitignore.txt");
#[derive(Debug, Error)]
enum Error {
#[error("A project file named default.project.json already exists in this folder")]
AlreadyExists,
#[error("git init failed")]
GitInit,
} }
fn init_inner(_options: InitCommand) -> Result<(), Error> { pub fn init(options: InitCommand) -> Result<(), anyhow::Error> {
unimplemented!("init command"); let base_path = options.absolute_path();
fs::create_dir_all(&base_path)?;
let canonical = fs::canonicalize(&base_path)?;
let project_name = canonical
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("new-project");
let project_params = ProjectParams {
name: project_name.to_owned(),
};
match options.kind {
InitKind::Place => init_place(&base_path, project_params),
InitKind::Model => init_model(&base_path, project_params),
}
}
fn init_place(base_path: &Path, project_params: ProjectParams) -> Result<(), anyhow::Error> {
eprintln!("Creating new place project '{}'", project_params.name);
let project_file = project_params.render_template(PLACE_PROJECT);
try_create_project(base_path, &project_file)?;
let readme = project_params.render_template(PLACE_README);
write_if_not_exists(&base_path.join("README.md"), &readme)?;
let src = base_path.join("src");
fs::create_dir_all(&src)?;
let src_shared = src.join("shared");
fs::create_dir_all(src.join(&src_shared))?;
let src_server = src.join("server");
fs::create_dir_all(src.join(&src_server))?;
let src_client = src.join("client");
fs::create_dir_all(src.join(&src_client))?;
write_if_not_exists(
&src_shared.join("Hello.lua"),
"return function()\n\tprint(\"Hello, world!\")\nend",
)?;
write_if_not_exists(
&src_server.join("init.server.lua"),
"print(\"Hello world, from server!\")",
)?;
write_if_not_exists(
&src_client.join("init.client.lua"),
"print(\"Hello world, from client!\")",
)?;
let git_ignore = project_params.render_template(PLACE_GIT_IGNORE);
try_git_init(base_path, &git_ignore)?;
eprintln!("Created project successfully.");
Ok(())
}
fn init_model(base_path: &Path, project_params: ProjectParams) -> Result<(), anyhow::Error> {
eprintln!("Creating new model project '{}'", project_params.name);
let project_file = project_params.render_template(MODEL_PROJECT);
try_create_project(base_path, &project_file)?;
let readme = project_params.render_template(MODEL_README);
write_if_not_exists(&base_path.join("README.md"), &readme)?;
let src = base_path.join("src");
fs::create_dir_all(&src)?;
let init = project_params.render_template(MODEL_INIT);
write_if_not_exists(&src.join("init.lua"), &init)?;
let git_ignore = project_params.render_template(MODEL_GIT_IGNORE);
try_git_init(base_path, &git_ignore)?;
eprintln!("Created project successfully.");
Ok(())
}
/// Contains parameters used in templates to create a project.
struct ProjectParams {
name: String,
}
impl ProjectParams {
/// Render a template by replacing variables with project parameters.
fn render_template(&self, template: &str) -> String {
template
.replace("{project_name}", &self.name)
.replace("{rojo_version}", env!("CARGO_PKG_VERSION"))
}
}
/// Attempt to initialize a Git repository if necessary, and create .gitignore.
fn try_git_init(path: &Path, git_ignore: &str) -> Result<(), anyhow::Error> {
if should_git_init(path) {
log::debug!("Initializing Git repository...");
let status = Command::new("git").arg("init").current_dir(path).status()?;
if !status.success() {
return Err(Error::GitInit.into());
}
}
write_if_not_exists(&path.join(".gitignore"), git_ignore)?;
Ok(())
}
/// Tells whether we should initialize a Git repository inside the given path.
///
/// Will return false if the user doesn't have Git installed or if the path is
/// already inside a Git repository.
fn should_git_init(path: &Path) -> bool {
let result = Command::new("git")
.args(&["rev-parse", "--is-inside-work-tree"])
.stdout(Stdio::null())
.stderr(Stdio::null())
.current_dir(path)
.status();
match result {
// If the command ran, but returned a non-zero exit code, we are not in
// a Git repo and we should initialize one.
Ok(status) => !status.success(),
// If the command failed to run, we probably don't have Git installed.
Err(_) => false,
}
}
/// Write a file if it does not exist yet, otherwise, leave it alone.
fn write_if_not_exists(path: &Path, contents: &str) -> Result<(), anyhow::Error> {
let file_res = OpenOptions::new().write(true).create_new(true).open(path);
let mut file = match file_res {
Ok(file) => file,
Err(err) => {
return match err.kind() {
io::ErrorKind::AlreadyExists => return Ok(()),
_ => Err(err.into()),
}
}
};
file.write_all(contents.as_bytes())?;
Ok(())
}
/// Try to create a project file and fail if it already exists.
fn try_create_project(base_path: &Path, contents: &str) -> Result<(), anyhow::Error> {
let project_path = base_path.join("default.project.json");
let file_res = OpenOptions::new()
.write(true)
.create_new(true)
.open(project_path);
let mut file = match file_res {
Ok(file) => file,
Err(err) => {
return match err.kind() {
io::ErrorKind::AlreadyExists => Err(Error::AlreadyExists.into()),
_ => Err(err.into()),
}
}
};
file.write_all(contents.as_bytes())?;
Ok(())
} }

View File

@@ -1,6 +1,7 @@
//! Defines Rojo's CLI through structopt types. //! Defines Rojo's CLI through structopt types.
mod build; mod build;
mod doc;
mod init; mod init;
mod serve; mod serve;
mod upload; mod upload;
@@ -15,8 +16,10 @@ use std::{
}; };
use structopt::StructOpt; use structopt::StructOpt;
use thiserror::Error;
pub use self::build::*; pub use self::build::*;
pub use self::doc::*;
pub use self::init::*; pub use self::init::*;
pub use self::serve::*; pub use self::serve::*;
pub use self::upload::*; pub use self::upload::*;
@@ -25,16 +28,73 @@ pub use self::upload::*;
#[derive(Debug, StructOpt)] #[derive(Debug, StructOpt)]
#[structopt(name = "Rojo", about, author)] #[structopt(name = "Rojo", about, author)]
pub struct Options { pub struct Options {
/// Sets verbosity level. Can be specified multiple times. #[structopt(flatten)]
#[structopt(long = "verbose", short, global(true), parse(from_occurrences))] pub global: GlobalOptions,
pub verbosity: u8,
/// Subcommand to run in this invocation. /// Subcommand to run in this invocation.
#[structopt(subcommand)] #[structopt(subcommand)]
pub subcommand: Subcommand, pub subcommand: Subcommand,
} }
/// All of Rojo's subcommands. #[derive(Debug, StructOpt)]
pub struct GlobalOptions {
/// Sets verbosity level. Can be specified multiple times.
#[structopt(long("verbose"), short, global(true), parse(from_occurrences))]
pub verbosity: u8,
/// Set color behavior. Valid values are auto, always, and never.
#[structopt(long("color"), global(true), default_value("auto"))]
pub color: ColorChoice,
}
#[derive(Debug, Clone, Copy)]
pub enum ColorChoice {
Auto,
Always,
Never,
}
impl FromStr for ColorChoice {
type Err = ColorChoiceParseError;
fn from_str(source: &str) -> Result<Self, Self::Err> {
match source {
"auto" => Ok(ColorChoice::Auto),
"always" => Ok(ColorChoice::Always),
"never" => Ok(ColorChoice::Never),
_ => Err(ColorChoiceParseError {
attempted: source.to_owned(),
}),
}
}
}
impl From<ColorChoice> for termcolor::ColorChoice {
fn from(value: ColorChoice) -> Self {
match value {
ColorChoice::Auto => termcolor::ColorChoice::Auto,
ColorChoice::Always => termcolor::ColorChoice::Always,
ColorChoice::Never => termcolor::ColorChoice::Never,
}
}
}
impl From<ColorChoice> for env_logger::WriteStyle {
fn from(value: ColorChoice) -> Self {
match value {
ColorChoice::Auto => env_logger::WriteStyle::Auto,
ColorChoice::Always => env_logger::WriteStyle::Always,
ColorChoice::Never => env_logger::WriteStyle::Never,
}
}
}
#[derive(Debug, Error)]
#[error("Invalid color choice '{attempted}'. Valid values are: auto, always, never")]
pub struct ColorChoiceParseError {
attempted: String,
}
#[derive(Debug, StructOpt)] #[derive(Debug, StructOpt)]
pub enum Subcommand { pub enum Subcommand {
/// Creates a new Rojo project. /// Creates a new Rojo project.
@@ -48,6 +108,9 @@ pub enum Subcommand {
/// Generates a place or model file out of the project and uploads it to Roblox. /// Generates a place or model file out of the project and uploads it to Roblox.
Upload(UploadCommand), Upload(UploadCommand),
/// Open Rojo's documentation in your browser.
Doc,
} }
/// Initializes a new Rojo project. /// Initializes a new Rojo project.
@@ -140,6 +203,10 @@ pub struct BuildCommand {
/// Where to output the result. /// Where to output the result.
#[structopt(long, short)] #[structopt(long, short)]
pub output: PathBuf, pub output: PathBuf,
/// Whether to automatically rebuild when any input files change.
#[structopt(long)]
pub watch: bool,
} }
impl BuildCommand { impl BuildCommand {

View File

@@ -3,30 +3,20 @@ use std::{
sync::Arc, sync::Arc,
}; };
use snafu::Snafu; use anyhow::Result;
use memofs::Vfs;
use termcolor::{BufferWriter, Color, ColorChoice, ColorSpec, WriteColor}; use termcolor::{BufferWriter, Color, ColorChoice, ColorSpec, WriteColor};
use crate::{ use crate::{
cli::ServeCommand, cli::{GlobalOptions, ServeCommand},
serve_session::ServeSession, serve_session::ServeSession,
vfs::{RealFetcher, Vfs, WatchMode},
web::LiveServer, web::LiveServer,
}; };
const DEFAULT_PORT: u16 = 34872; const DEFAULT_PORT: u16 = 34872;
#[derive(Debug, Snafu)] pub fn serve(global: GlobalOptions, options: ServeCommand) -> Result<()> {
pub struct ServeError(Error); let vfs = Vfs::new_default();
#[derive(Debug, Snafu)]
enum Error {}
pub fn serve(options: ServeCommand) -> Result<(), ServeError> {
Ok(serve_inner(options)?)
}
fn serve_inner(options: ServeCommand) -> Result<(), Error> {
let vfs = Vfs::new(RealFetcher::new(WatchMode::Enabled));
let session = Arc::new(ServeSession::new(vfs, &options.absolute_project())); let session = Arc::new(ServeSession::new(vfs, &options.absolute_project()));
@@ -37,14 +27,14 @@ fn serve_inner(options: ServeCommand) -> Result<(), Error> {
let server = LiveServer::new(session); let server = LiveServer::new(session);
let _ = show_start_message(port); let _ = show_start_message(port, global.color.into());
server.start(port); server.start(port);
Ok(()) Ok(())
} }
fn show_start_message(port: u16) -> io::Result<()> { fn show_start_message(port: u16, color: ColorChoice) -> io::Result<()> {
let writer = BufferWriter::stdout(ColorChoice::Auto); let writer = BufferWriter::stdout(color);
let mut buffer = writer.buffer(); let mut buffer = writer.buffer();
writeln!(&mut buffer, "Rojo server listening:")?; writeln!(&mut buffer, "Rojo server listening:")?;

View File

@@ -1,49 +1,30 @@
use memofs::Vfs;
use reqwest::header::{ACCEPT, CONTENT_TYPE, COOKIE, USER_AGENT}; use reqwest::header::{ACCEPT, CONTENT_TYPE, COOKIE, USER_AGENT};
use snafu::{ResultExt, Snafu}; use thiserror::Error;
use crate::{ use crate::{auth_cookie::get_auth_cookie, cli::UploadCommand, serve_session::ServeSession};
auth_cookie::get_auth_cookie,
cli::UploadCommand,
common_setup,
vfs::{RealFetcher, Vfs, WatchMode},
};
#[derive(Debug, Snafu)] #[derive(Debug, Error)]
pub struct UploadError(Error);
#[derive(Debug, Snafu)]
enum Error { enum Error {
#[snafu(display( #[error("Rojo could not find your Roblox auth cookie. Please pass one via --cookie.")]
"Rojo could not find your Roblox auth cookie. Please pass one via --cookie.",
))]
NeedAuthCookie, NeedAuthCookie,
#[snafu(display("XML model file encode error: {}", source))] #[error("The Roblox API returned an unexpected error: {body}")]
XmlModel { source: rbx_xml::EncodeError },
#[snafu(display("HTTP error: {}", source))]
Http { source: reqwest::Error },
#[snafu(display("Roblox API error: {}", body))]
RobloxApi { body: String }, RobloxApi { body: String },
} }
pub fn upload(options: UploadCommand) -> Result<(), UploadError> { pub fn upload(options: UploadCommand) -> Result<(), anyhow::Error> {
Ok(upload_inner(options)?)
}
fn upload_inner(options: UploadCommand) -> Result<(), Error> {
let cookie = options let cookie = options
.cookie .cookie
.clone() .clone()
.or_else(get_auth_cookie) .or_else(get_auth_cookie)
.ok_or(Error::NeedAuthCookie)?; .ok_or(Error::NeedAuthCookie)?;
log::trace!("Constructing in-memory filesystem"); let vfs = Vfs::new_default();
let vfs = Vfs::new(RealFetcher::new(WatchMode::Disabled));
let (_maybe_project, tree) = common_setup::start(&options.absolute_project(), &vfs); let session = ServeSession::new(vfs, &options.absolute_project());
let tree = session.tree();
let inner_tree = tree.inner(); let inner_tree = tree.inner();
let root_id = inner_tree.get_root_id(); let root_id = inner_tree.get_root_id();
let root_instance = inner_tree.get_instance(root_id).unwrap(); let root_instance = inner_tree.get_instance(root_id).unwrap();
@@ -59,7 +40,7 @@ fn upload_inner(options: UploadCommand) -> Result<(), Error> {
let config = rbx_xml::EncodeOptions::new() let config = rbx_xml::EncodeOptions::new()
.property_behavior(rbx_xml::EncodePropertyBehavior::WriteUnknown); .property_behavior(rbx_xml::EncodePropertyBehavior::WriteUnknown);
rbx_xml::to_writer(&mut buffer, tree.inner(), &encode_ids, config).context(XmlModel)?; rbx_xml::to_writer(&mut buffer, tree.inner(), &encode_ids, config)?;
let url = format!( let url = format!(
"https://data.roblox.com/Data/Upload.ashx?assetid={}", "https://data.roblox.com/Data/Upload.ashx?assetid={}",
@@ -76,13 +57,13 @@ fn upload_inner(options: UploadCommand) -> Result<(), Error> {
.header(CONTENT_TYPE, "application/xml") .header(CONTENT_TYPE, "application/xml")
.header(ACCEPT, "application/json") .header(ACCEPT, "application/json")
.body(buffer) .body(buffer)
.send() .send()?;
.context(Http)?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(Error::RobloxApi { return Err(Error::RobloxApi {
body: response.text().context(Http)?, body: response.text()?,
}); }
.into());
} }
Ok(()) Ok(())

View File

@@ -1,65 +0,0 @@
//! Initialization routines that are used by more than one Rojo command or
//! utility.
use std::path::Path;
use rbx_dom_weak::RbxInstanceProperties;
use crate::{
project::Project,
snapshot::{
apply_patch_set, compute_patch_set, InstanceContext, InstancePropertiesWithMeta,
PathIgnoreRule, RojoTree,
},
snapshot_middleware::snapshot_from_vfs,
vfs::{Vfs, VfsFetcher},
};
pub fn start<F: VfsFetcher>(
fuzzy_project_path: &Path,
vfs: &Vfs<F>,
) -> (Option<Project>, RojoTree) {
log::trace!("Loading project file from {}", fuzzy_project_path.display());
let maybe_project = Project::load_fuzzy(fuzzy_project_path).expect("TODO: Project load failed");
log::trace!("Constructing initial tree");
let mut tree = RojoTree::new(InstancePropertiesWithMeta {
properties: RbxInstanceProperties {
name: "ROOT".to_owned(),
class_name: "Folder".to_owned(),
properties: Default::default(),
},
metadata: Default::default(),
});
let root_id = tree.get_root_id();
log::trace!("Reading project root");
let entry = vfs
.get(fuzzy_project_path)
.expect("could not get project path");
let mut instance_context = InstanceContext::default();
if let Some(project) = &maybe_project {
let rules = project.glob_ignore_paths.iter().map(|glob| PathIgnoreRule {
glob: glob.clone(),
base_path: project.folder_location().to_path_buf(),
});
instance_context.add_path_ignore_rules(rules);
}
log::trace!("Generating snapshot of instances from VFS");
let snapshot = snapshot_from_vfs(&instance_context, vfs, &entry)
.expect("snapshot failed")
.expect("snapshot did not return an instance");
log::trace!("Computing patch set");
let patch_set = compute_patch_set(&snapshot, &tree, root_id);
log::trace!("Applying patch set");
apply_patch_set(&mut tree, patch_set);
(maybe_project, tree)
}

18
src/error.rs Normal file
View File

@@ -0,0 +1,18 @@
use std::{error::Error, fmt};
/// Wrapper type to print errors with source-chasing.
pub struct ErrorDisplay<E>(pub E);
impl<E: Error> fmt::Display for ErrorDisplay<E> {
fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
writeln!(formatter, "{}", self.0)?;
let mut current_err: &dyn Error = &self.0;
while let Some(source) = current_err.source() {
writeln!(formatter, " caused by {}", source)?;
current_err = &*source;
}
Ok(())
}
}

View File

@@ -9,18 +9,16 @@ mod tree_view;
mod auth_cookie; mod auth_cookie;
mod change_processor; mod change_processor;
mod common_setup; mod error;
mod glob; mod glob;
mod message_queue; mod message_queue;
mod multimap; mod multimap;
mod path_map;
mod path_serializer; mod path_serializer;
mod project; mod project;
mod serve_session; mod serve_session;
mod session_id; mod session_id;
mod snapshot; mod snapshot;
mod snapshot_middleware; mod snapshot_middleware;
mod vfs;
mod web; mod web;
pub use project::*; pub use project::*;

View File

@@ -1,282 +0,0 @@
use std::{
collections::{BTreeSet, HashMap},
path::{Path, PathBuf},
};
use log::warn;
use serde::Serialize;
#[derive(Debug, Serialize)]
struct PathMapNode<T> {
value: T,
children: BTreeSet<PathBuf>,
}
/// A map from paths to another type, like instance IDs, with a bit of
/// additional data that enables removing a path and all of its child paths from
/// the tree more quickly.
#[derive(Debug, Serialize)]
pub struct PathMap<T> {
nodes: HashMap<PathBuf, PathMapNode<T>>,
/// Contains the set of all paths whose parent either does not exist, or is
/// not present in the PathMap.
///
/// Note that these paths may have other _ancestors_ in the tree, but if an
/// orphan's parent path is ever inserted, it will stop being an orphan. It
/// will be... adopted!
orphan_paths: BTreeSet<PathBuf>,
}
impl<T> Default for PathMap<T> {
fn default() -> Self {
Self::new()
}
}
impl<T> PathMap<T> {
pub fn new() -> PathMap<T> {
PathMap {
nodes: HashMap::new(),
orphan_paths: BTreeSet::new(),
}
}
pub fn get(&self, path: impl AsRef<Path>) -> Option<&T> {
self.nodes.get(path.as_ref()).map(|v| &v.value)
}
pub fn get_mut(&mut self, path: impl AsRef<Path>) -> Option<&mut T> {
self.nodes.get_mut(path.as_ref()).map(|v| &mut v.value)
}
pub fn children(&self, path: impl AsRef<Path>) -> Option<Vec<&Path>> {
self.nodes
.get(path.as_ref())
.map(|v| v.children.iter().map(AsRef::as_ref).collect())
}
pub fn contains_key(&self, path: impl AsRef<Path>) -> bool {
self.nodes.contains_key(path.as_ref())
}
pub fn insert(&mut self, path: impl Into<PathBuf>, value: T) {
let path = path.into();
self.add_to_parent(path.clone());
// Collect any children that are currently marked as orphaned paths, but
// are actually children of this new node.
let mut children = BTreeSet::new();
for orphan_path in &self.orphan_paths {
if orphan_path.parent() == Some(&path) {
children.insert(orphan_path.clone());
}
}
for child in &children {
self.orphan_paths.remove(child);
}
self.nodes.insert(path, PathMapNode { value, children });
}
/// Remove the given path and all of its linked descendants, returning all
/// values stored in the map.
pub fn remove(&mut self, root_path: impl AsRef<Path>) -> Vec<(PathBuf, T)> {
let root_path = root_path.as_ref();
self.remove_from_parent(root_path);
let (root_path, root_node) = match self.nodes.remove_entry(root_path) {
Some(node) => node,
None => return Vec::new(),
};
let mut removed_entries = vec![(root_path, root_node.value)];
let mut to_visit: Vec<PathBuf> = root_node.children.into_iter().collect();
while let Some(path) = to_visit.pop() {
match self.nodes.remove_entry(&path) {
Some((path, node)) => {
removed_entries.push((path, node.value));
for child in node.children.into_iter() {
to_visit.push(child);
}
}
None => {
warn!(
"Consistency issue; tried to remove {} but it was already removed",
path.display()
);
}
}
}
removed_entries
}
pub fn orphans(&self) -> impl Iterator<Item = &Path> {
self.orphan_paths.iter().map(|item| item.as_ref())
}
/// Adds the path to its parent if it's present in the tree, or the set of
/// orphaned paths if it is not.
fn add_to_parent(&mut self, path: PathBuf) {
if let Some(parent_path) = path.parent() {
if let Some(parent) = self.nodes.get_mut(parent_path) {
parent.children.insert(path);
return;
}
}
// In this branch, the path is orphaned because it either doesn't have a
// parent according to Path, or because its parent doesn't exist in the
// PathMap.
self.orphan_paths.insert(path);
}
/// Removes the path from its parent, or from the orphaned paths set if it
/// has no parent.
fn remove_from_parent(&mut self, path: &Path) {
if let Some(parent_path) = path.parent() {
if let Some(parent) = self.nodes.get_mut(parent_path) {
parent.children.remove(path);
return;
}
}
// In this branch, the path is orphaned because it either doesn't have a
// parent according to Path, or because its parent doesn't exist in the
// PathMap.
self.orphan_paths.remove(path);
}
}
#[cfg(test)]
mod test {
use super::*;
use maplit::btreeset;
#[test]
fn smoke_test() {
let mut map = PathMap::new();
assert_eq!(map.get("/foo"), None);
map.insert("/foo", 5);
assert_eq!(map.get("/foo"), Some(&5));
map.insert("/foo/bar", 6);
assert_eq!(map.get("/foo"), Some(&5));
assert_eq!(map.get("/foo/bar"), Some(&6));
assert_eq!(map.children("/foo"), Some(vec![Path::new("/foo/bar")]));
}
#[test]
fn orphans() {
let mut map = PathMap::new();
map.insert("/foo/bar", 5);
assert_eq!(map.orphan_paths, btreeset!["/foo/bar".into()]);
map.insert("/foo", 6);
assert_eq!(map.orphan_paths, btreeset!["/foo".into()]);
}
#[test]
fn remove_one() {
let mut map = PathMap::new();
map.insert("/foo", 6);
assert_eq!(map.remove("/foo"), vec![(PathBuf::from("/foo"), 6),]);
assert_eq!(map.get("/foo"), None);
}
#[test]
fn remove_child() {
let mut map = PathMap::new();
map.insert("/foo", 6);
map.insert("/foo/bar", 12);
assert_eq!(
map.remove("/foo"),
vec![(PathBuf::from("/foo"), 6), (PathBuf::from("/foo/bar"), 12),]
);
assert_eq!(map.get("/foo"), None);
assert_eq!(map.get("/foo/bar"), None);
}
#[test]
fn remove_descendant() {
let mut map = PathMap::new();
map.insert("/foo", 6);
map.insert("/foo/bar", 12);
map.insert("/foo/bar/baz", 18);
assert_eq!(
map.remove("/foo"),
vec![
(PathBuf::from("/foo"), 6),
(PathBuf::from("/foo/bar"), 12),
(PathBuf::from("/foo/bar/baz"), 18),
]
);
assert_eq!(map.get("/foo"), None);
assert_eq!(map.get("/foo/bar"), None);
assert_eq!(map.get("/foo/bar/baz"), None);
}
#[test]
fn remove_not_orphan_descendants() {
let mut map = PathMap::new();
map.insert("/foo", 6);
map.insert("/foo/bar/baz", 12);
assert_eq!(map.remove("/foo"), vec![(PathBuf::from("/foo"), 6),]);
assert_eq!(map.get("/foo"), None);
assert_eq!(map.get("/foo/bar/baz"), Some(&12));
}
// Makes sure that regardless of addition order, paths are always sorted
// when asking for children.
#[test]
fn add_order_sorted() {
let mut map = PathMap::new();
map.insert("/foo", 5);
map.insert("/foo/b", 2);
map.insert("/foo/d", 0);
map.insert("/foo/c", 3);
assert_eq!(
map.children("/foo"),
Some(vec![
Path::new("/foo/b"),
Path::new("/foo/c"),
Path::new("/foo/d"),
])
);
map.insert("/foo/a", 1);
assert_eq!(
map.children("/foo"),
Some(vec![
Path::new("/foo/a"),
Path::new("/foo/b"),
Path::new("/foo/c"),
Path::new("/foo/d"),
])
);
}
}

View File

@@ -6,22 +6,26 @@ use std::{
use rbx_dom_weak::UnresolvedRbxValue; use rbx_dom_weak::UnresolvedRbxValue;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use snafu::{ResultExt, Snafu}; use thiserror::Error;
use crate::glob::Glob; use crate::glob::Glob;
static PROJECT_FILENAME: &str = "default.project.json"; static PROJECT_FILENAME: &str = "default.project.json";
/// Error type returned by any function that handles projects. /// Error type returned by any function that handles projects.
#[derive(Debug, Snafu)] #[derive(Debug, Error)]
pub struct ProjectError(Error); #[error(transparent)]
pub struct ProjectError(#[from] Error);
#[derive(Debug, Snafu)] #[derive(Debug, Error)]
enum Error { enum Error {
/// A general IO error occurred. #[error("Rojo project I/O error")]
Io { source: io::Error, path: PathBuf }, Io {
#[from]
source: io::Error,
},
/// An error with JSON parsing occurred. #[error("Error parsing Rojo project")]
Json { Json {
source: serde_json::Error, source: serde_json::Error,
path: PathBuf, path: PathBuf,
@@ -125,14 +129,14 @@ impl Project {
} }
} }
fn load_exact(project_file_location: &Path) -> Result<Self, ProjectError> { fn load_exact(project_file_location: &Path) -> Result<Self, Error> {
let contents = fs::read_to_string(project_file_location).context(Io { let contents = fs::read_to_string(project_file_location)?;
path: project_file_location,
})?;
let mut project: Project = serde_json::from_str(&contents).context(Json { let mut project: Project =
path: project_file_location, serde_json::from_str(&contents).map_err(|source| Error::Json {
})?; source,
path: project_file_location.to_owned(),
})?;
project.file_location = project_file_location.to_path_buf(); project.file_location = project_file_location.to_path_buf();
project.check_compatibility(); project.check_compatibility();
@@ -140,10 +144,6 @@ impl Project {
Ok(project) Ok(project)
} }
pub fn save(&self) -> Result<(), ProjectError> {
unimplemented!()
}
/// Checks if there are any compatibility issues with this project file and /// Checks if there are any compatibility issues with this project file and
/// warns the user if there are any. /// warns the user if there are any.
fn check_compatibility(&self) { fn check_compatibility(&self) {

View File

@@ -6,15 +6,19 @@ use std::{
}; };
use crossbeam_channel::Sender; use crossbeam_channel::Sender;
use memofs::Vfs;
use rbx_dom_weak::RbxInstanceProperties;
use crate::{ use crate::{
change_processor::ChangeProcessor, change_processor::ChangeProcessor,
common_setup,
message_queue::MessageQueue, message_queue::MessageQueue,
project::Project, project::Project,
session_id::SessionId, session_id::SessionId,
snapshot::{AppliedPatchSet, PatchSet, RojoTree}, snapshot::{
vfs::{Vfs, VfsFetcher}, apply_patch_set, compute_patch_set, AppliedPatchSet, InstanceContext,
InstancePropertiesWithMeta, PatchSet, PathIgnoreRule, RojoTree,
},
snapshot_middleware::snapshot_from_vfs,
}; };
/// Contains all of the state for a Rojo serve session. /// Contains all of the state for a Rojo serve session.
@@ -24,7 +28,7 @@ use crate::{
/// why Rojo couldn't expose an IPC or channels-based API for embedding in the /// why Rojo couldn't expose an IPC or channels-based API for embedding in the
/// future. `ServeSession` would be roughly the right interface to expose for /// future. `ServeSession` would be roughly the right interface to expose for
/// those cases. /// those cases.
pub struct ServeSession<F> { pub struct ServeSession {
/// The object responsible for listening to changes from the in-memory /// The object responsible for listening to changes from the in-memory
/// filesystem, applying them, updating the Roblox instance tree, and /// filesystem, applying them, updating the Roblox instance tree, and
/// routing messages through the session's message queue to any connected /// routing messages through the session's message queue to any connected
@@ -68,7 +72,7 @@ pub struct ServeSession<F> {
/// ///
/// The main use for accessing it from the session is for debugging issues /// The main use for accessing it from the session is for debugging issues
/// with Rojo's live-sync protocol. /// with Rojo's live-sync protocol.
vfs: Arc<Vfs<F>>, vfs: Arc<Vfs>,
/// A queue of changes that have been applied to `tree` that affect clients. /// A queue of changes that have been applied to `tree` that affect clients.
/// ///
@@ -85,21 +89,54 @@ pub struct ServeSession<F> {
/// Methods that need thread-safety bounds on VfsFetcher are limited to this /// Methods that need thread-safety bounds on VfsFetcher are limited to this
/// block to prevent needing to spread Send + Sync + 'static into everything /// block to prevent needing to spread Send + Sync + 'static into everything
/// that handles ServeSession. /// that handles ServeSession.
impl<F: VfsFetcher + Send + Sync + 'static> ServeSession<F> { impl ServeSession {
/// Start a new serve session from the given in-memory filesystem and start /// Start a new serve session from the given in-memory filesystem and start
/// path. /// path.
/// ///
/// The project file is expected to be loaded out-of-band since it's /// The project file is expected to be loaded out-of-band since it's
/// currently loaded from the filesystem directly instead of through the /// currently loaded from the filesystem directly instead of through the
/// in-memory filesystem layer. /// in-memory filesystem layer.
pub fn new<P: AsRef<Path>>(vfs: Vfs<F>, start_path: P) -> Self { pub fn new<P: AsRef<Path>>(vfs: Vfs, start_path: P) -> Self {
let start_path = start_path.as_ref(); let start_path = start_path.as_ref();
log::trace!("Starting new ServeSession at path {}", start_path.display(),);
let start_time = Instant::now(); let start_time = Instant::now();
let (root_project, tree) = common_setup::start(start_path, &vfs); log::trace!("Starting new ServeSession at path {}", start_path.display());
log::trace!("Loading project file from {}", start_path.display());
let root_project = Project::load_fuzzy(start_path).expect("TODO: Project load failed");
let mut tree = RojoTree::new(InstancePropertiesWithMeta {
properties: RbxInstanceProperties {
name: "ROOT".to_owned(),
class_name: "Folder".to_owned(),
properties: Default::default(),
},
metadata: Default::default(),
});
let root_id = tree.get_root_id();
let mut instance_context = InstanceContext::default();
if let Some(project) = &root_project {
let rules = project.glob_ignore_paths.iter().map(|glob| PathIgnoreRule {
glob: glob.clone(),
base_path: project.folder_location().to_path_buf(),
});
instance_context.add_path_ignore_rules(rules);
}
log::trace!("Generating snapshot of instances from VFS");
let snapshot = snapshot_from_vfs(&instance_context, &vfs, &start_path)
.expect("snapshot failed")
.expect("snapshot did not return an instance");
log::trace!("Computing initial patch set");
let patch_set = compute_patch_set(&snapshot, &tree, root_id);
log::trace!("Applying initial patch set");
apply_patch_set(&mut tree, patch_set);
let session_id = SessionId::new(); let session_id = SessionId::new();
let message_queue = MessageQueue::new(); let message_queue = MessageQueue::new();
@@ -131,7 +168,7 @@ impl<F: VfsFetcher + Send + Sync + 'static> ServeSession<F> {
} }
} }
impl<F: VfsFetcher> ServeSession<F> { impl ServeSession {
pub fn tree_handle(&self) -> Arc<Mutex<RojoTree>> { pub fn tree_handle(&self) -> Arc<Mutex<RojoTree>> {
Arc::clone(&self.tree) Arc::clone(&self.tree)
} }
@@ -144,7 +181,7 @@ impl<F: VfsFetcher> ServeSession<F> {
self.tree_mutation_sender.clone() self.tree_mutation_sender.clone()
} }
pub fn vfs(&self) -> &Vfs<F> { pub fn vfs(&self) -> &Vfs {
&self.vfs &self.vfs
} }
@@ -189,201 +226,201 @@ mod serve_session {
use std::{path::PathBuf, time::Duration}; use std::{path::PathBuf, time::Duration};
use insta::assert_yaml_snapshot;
use maplit::hashmap; use maplit::hashmap;
use memofs::{InMemoryFs, VfsEvent, VfsSnapshot};
use rojo_insta_ext::RedactionMap; use rojo_insta_ext::RedactionMap;
use tokio::{runtime::Runtime, timer::Timeout}; use tokio::{runtime::Runtime, timer::Timeout};
use crate::{ use crate::tree_view::view_tree;
tree_view::view_tree,
vfs::{NoopFetcher, TestFetcher, VfsDebug, VfsEvent, VfsSnapshot},
};
#[test] #[test]
fn just_folder() { fn just_folder() {
let vfs = Vfs::new(NoopFetcher); let mut imfs = InMemoryFs::new();
imfs.load_snapshot("/foo", VfsSnapshot::empty_dir())
.unwrap();
vfs.debug_load_snapshot("/foo", VfsSnapshot::empty_dir()); let vfs = Vfs::new(imfs);
let session = ServeSession::new(vfs, "/foo"); let session = ServeSession::new(vfs, "/foo");
let mut rm = RedactionMap::new(); let mut rm = RedactionMap::new();
assert_yaml_snapshot!(view_tree(&session.tree(), &mut rm)); insta::assert_yaml_snapshot!(view_tree(&session.tree(), &mut rm));
} }
#[test] #[test]
fn project_with_folder() { fn project_with_folder() {
let vfs = Vfs::new(NoopFetcher); let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
vfs.debug_load_snapshot(
"/foo", "/foo",
VfsSnapshot::dir(hashmap! { VfsSnapshot::dir(hashmap! {
"default.project.json" => VfsSnapshot::file(r#" "default.project.json" => VfsSnapshot::file(r#"
{ {
"name": "HelloWorld", "name": "HelloWorld",
"tree": { "tree": {
"$path": "src" "$path": "src"
}
} }
} "#),
"#),
"src" => VfsSnapshot::dir(hashmap! { "src" => VfsSnapshot::dir(hashmap! {
"hello.txt" => VfsSnapshot::file("Hello, world!"), "hello.txt" => VfsSnapshot::file("Hello, world!"),
}), }),
}), }),
); )
.unwrap();
let vfs = Vfs::new(imfs);
let session = ServeSession::new(vfs, "/foo"); let session = ServeSession::new(vfs, "/foo");
let mut rm = RedactionMap::new(); let mut rm = RedactionMap::new();
assert_yaml_snapshot!(view_tree(&session.tree(), &mut rm)); insta::assert_yaml_snapshot!(view_tree(&session.tree(), &mut rm));
} }
#[test] #[test]
fn script_with_meta() { fn script_with_meta() {
let vfs = Vfs::new(NoopFetcher); let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
vfs.debug_load_snapshot(
"/root", "/root",
VfsSnapshot::dir(hashmap! { VfsSnapshot::dir(hashmap! {
"test.lua" => VfsSnapshot::file("This is a test."), "test.lua" => VfsSnapshot::file("This is a test."),
"test.meta.json" => VfsSnapshot::file(r#"{ "ignoreUnknownInstances": true }"#), "test.meta.json" => VfsSnapshot::file(r#"{ "ignoreUnknownInstances": true }"#),
}), }),
); )
.unwrap();
let vfs = Vfs::new(imfs);
let session = ServeSession::new(vfs, "/root"); let session = ServeSession::new(vfs, "/root");
let mut rm = RedactionMap::new(); let mut rm = RedactionMap::new();
assert_yaml_snapshot!(view_tree(&session.tree(), &mut rm)); insta::assert_yaml_snapshot!(view_tree(&session.tree(), &mut rm));
}
#[test]
fn change_script_meta() {
let (state, fetcher) = TestFetcher::new();
state.load_snapshot(
"/root",
VfsSnapshot::dir(hashmap! {
"test.lua" => VfsSnapshot::file("This is a test."),
"test.meta.json" => VfsSnapshot::file(r#"{ "ignoreUnknownInstances": true }"#),
}),
);
let vfs = Vfs::new(fetcher);
let session = ServeSession::new(vfs, "/root");
let mut redactions = RedactionMap::new();
assert_yaml_snapshot!(
"change_script_meta_before",
view_tree(&session.tree(), &mut redactions)
);
state.load_snapshot(
"/root/test.meta.json",
VfsSnapshot::file(r#"{ "ignoreUnknownInstances": false }"#),
);
let receiver = Timeout::new(
session.message_queue().subscribe_any(),
Duration::from_millis(200),
);
state.raise_event(VfsEvent::Modified(PathBuf::from("/root/test.meta.json")));
let mut rt = Runtime::new().unwrap();
let changes = rt.block_on(receiver).unwrap();
assert_yaml_snapshot!(
"change_script_meta_patch",
redactions.redacted_yaml(changes)
);
assert_yaml_snapshot!(
"change_script_meta_after",
view_tree(&session.tree(), &mut redactions)
);
} }
#[test] #[test]
fn change_txt_file() { fn change_txt_file() {
let (state, fetcher) = TestFetcher::new(); let mut imfs = InMemoryFs::new();
imfs.load_snapshot("/foo.txt", VfsSnapshot::file("Hello!"))
.unwrap();
state.load_snapshot("/foo.txt", VfsSnapshot::file("Hello!")); let vfs = Vfs::new(imfs.clone());
let vfs = Vfs::new(fetcher);
let session = ServeSession::new(vfs, "/foo.txt"); let session = ServeSession::new(vfs, "/foo.txt");
let mut redactions = RedactionMap::new(); let mut rm = RedactionMap::new();
assert_yaml_snapshot!( insta::assert_yaml_snapshot!(
"change_txt_file_before", "change_txt_file_before",
view_tree(&session.tree(), &mut redactions) view_tree(&session.tree(), &mut rm)
); );
state.load_snapshot("/foo.txt", VfsSnapshot::file("World!")); imfs.load_snapshot("/foo.txt", VfsSnapshot::file("World!"))
.unwrap();
let receiver = session.message_queue().subscribe_any(); let receiver = session.message_queue().subscribe_any();
state.raise_event(VfsEvent::Modified(PathBuf::from("/foo.txt"))); imfs.raise_event(VfsEvent::Write(PathBuf::from("/foo.txt")));
let receiver = Timeout::new(receiver, Duration::from_millis(200)); let receiver = Timeout::new(receiver, Duration::from_millis(200));
let mut rt = Runtime::new().unwrap(); let mut rt = Runtime::new().unwrap();
let result = rt.block_on(receiver).unwrap(); let result = rt.block_on(receiver).unwrap();
assert_yaml_snapshot!("change_txt_file_patch", redactions.redacted_yaml(result)); insta::assert_yaml_snapshot!("change_txt_file_patch", rm.redacted_yaml(result));
assert_yaml_snapshot!( insta::assert_yaml_snapshot!("change_txt_file_after", view_tree(&session.tree(), &mut rm));
"change_txt_file_after", }
view_tree(&session.tree(), &mut redactions)
#[test]
fn change_script_meta() {
let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
"/root",
VfsSnapshot::dir(hashmap! {
"test.lua" => VfsSnapshot::file("This is a test."),
"test.meta.json" => VfsSnapshot::file(r#"{ "ignoreUnknownInstances": true }"#),
}),
)
.unwrap();
let vfs = Vfs::new(imfs.clone());
let session = ServeSession::new(vfs, "/root");
let mut rm = RedactionMap::new();
insta::assert_yaml_snapshot!(
"change_script_meta_before",
view_tree(&session.tree(), &mut rm)
);
imfs.load_snapshot(
"/root/test.meta.json",
VfsSnapshot::file(r#"{ "ignoreUnknownInstances": false }"#),
)
.unwrap();
let receiver = session.message_queue().subscribe_any();
imfs.raise_event(VfsEvent::Write(PathBuf::from("/root/test.meta.json")));
let receiver = Timeout::new(receiver, Duration::from_millis(200));
let mut rt = Runtime::new().unwrap();
let result = rt.block_on(receiver).unwrap();
insta::assert_yaml_snapshot!("change_script_meta_patch", rm.redacted_yaml(result));
insta::assert_yaml_snapshot!(
"change_script_meta_after",
view_tree(&session.tree(), &mut rm)
); );
} }
#[test] #[test]
fn change_file_in_project() { fn change_file_in_project() {
let (state, fetcher) = TestFetcher::new(); let mut imfs = InMemoryFs::new();
imfs.load_snapshot(
state.load_snapshot(
"/foo", "/foo",
VfsSnapshot::dir(hashmap! { VfsSnapshot::dir(hashmap! {
"default.project.json" => VfsSnapshot::file(r#" "default.project.json" => VfsSnapshot::file(r#"
{ {
"name": "change_file_in_project", "name": "change_file_in_project",
"tree": { "tree": {
"$className": "Folder", "$className": "Folder",
"Child": { "Child": {
"$path": "file.txt" "$path": "file.txt"
}
} }
} }
} "#),
"#),
"file.txt" => VfsSnapshot::file("initial content"), "file.txt" => VfsSnapshot::file("initial content"),
}), }),
); )
.unwrap();
let vfs = Vfs::new(imfs.clone());
let vfs = Vfs::new(fetcher);
let session = ServeSession::new(vfs, "/foo"); let session = ServeSession::new(vfs, "/foo");
let mut redactions = RedactionMap::new(); let mut rm = RedactionMap::new();
assert_yaml_snapshot!( insta::assert_yaml_snapshot!(
"change_file_in_project_before", "change_file_in_project_before",
view_tree(&session.tree(), &mut redactions) view_tree(&session.tree(), &mut rm)
); );
state.load_snapshot("/foo/file.txt", VfsSnapshot::file("Changed!")); imfs.load_snapshot("/foo/file.txt", VfsSnapshot::file("Changed!"))
.unwrap();
let receiver = session.message_queue().subscribe_any(); let receiver = session.message_queue().subscribe_any();
state.raise_event(VfsEvent::Modified(PathBuf::from("/foo/file.txt"))); imfs.raise_event(VfsEvent::Write(PathBuf::from("/foo/file.txt")));
let receiver = Timeout::new(receiver, Duration::from_millis(200)); let receiver = Timeout::new(receiver, Duration::from_millis(200));
let mut rt = Runtime::new().unwrap(); let mut rt = Runtime::new().unwrap();
let result = rt.block_on(receiver).unwrap(); let result = rt.block_on(receiver).unwrap();
assert_yaml_snapshot!( insta::assert_yaml_snapshot!("change_file_in_project_patch", rm.redacted_yaml(result));
"change_file_in_project_patch", insta::assert_yaml_snapshot!(
redactions.redacted_yaml(result)
);
assert_yaml_snapshot!(
"change_file_in_project_after", "change_file_in_project_after",
view_tree(&session.tree(), &mut redactions) view_tree(&session.tree(), &mut rm)
); );
} }
} }

View File

@@ -1,13 +1,11 @@
use std::collections::BTreeMap; use std::{collections::BTreeMap, path::Path};
use maplit::hashmap; use maplit::hashmap;
use memofs::{IoResultExt, Vfs};
use rbx_dom_weak::RbxValue; use rbx_dom_weak::RbxValue;
use serde::Serialize; use serde::Serialize;
use crate::{ use crate::snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot};
snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot},
vfs::{FsResultExt, Vfs, VfsEntry, VfsFetcher},
};
use super::{ use super::{
meta_file::AdjacentMetadata, meta_file::AdjacentMetadata,
@@ -18,25 +16,21 @@ use super::{
pub struct SnapshotCsv; pub struct SnapshotCsv;
impl SnapshotMiddleware for SnapshotCsv { impl SnapshotMiddleware for SnapshotCsv {
fn from_vfs<F: VfsFetcher>( fn from_vfs(_context: &InstanceContext, vfs: &Vfs, path: &Path) -> SnapshotInstanceResult {
_context: &InstanceContext, let meta = vfs.metadata(path)?;
vfs: &Vfs<F>,
entry: &VfsEntry, if meta.is_dir() {
) -> SnapshotInstanceResult {
if entry.is_directory() {
return Ok(None); return Ok(None);
} }
let instance_name = match match_file_name(entry.path(), ".csv") { let instance_name = match match_file_name(path, ".csv") {
Some(name) => name, Some(name) => name,
None => return Ok(None), None => return Ok(None),
}; };
let meta_path = entry let meta_path = path.with_file_name(format!("{}.meta.json", instance_name));
.path()
.with_file_name(format!("{}.meta.json", instance_name));
let table_contents = convert_localization_csv(&entry.contents(vfs)?); let table_contents = convert_localization_csv(&vfs.read(path)?);
let mut snapshot = InstanceSnapshot::new() let mut snapshot = InstanceSnapshot::new()
.name(instance_name) .name(instance_name)
@@ -48,13 +42,12 @@ impl SnapshotMiddleware for SnapshotCsv {
}) })
.metadata( .metadata(
InstanceMetadata::new() InstanceMetadata::new()
.instigating_source(entry.path()) .instigating_source(path)
.relevant_paths(vec![entry.path().to_path_buf(), meta_path.clone()]), .relevant_paths(vec![path.to_path_buf(), meta_path.clone()]),
); );
if let Some(meta_entry) = vfs.get(meta_path).with_not_found()? { if let Some(meta_contents) = vfs.read(&meta_path).with_not_found()? {
let meta_contents = meta_entry.contents(vfs)?; let mut metadata = AdjacentMetadata::from_slice(&meta_contents, &meta_path)?;
let mut metadata = AdjacentMetadata::from_slice(&meta_contents);
metadata.apply_all(&mut snapshot); metadata.apply_all(&mut snapshot);
} }
@@ -142,48 +135,54 @@ fn convert_localization_csv(contents: &[u8]) -> String {
mod test { mod test {
use super::*; use super::*;
use crate::vfs::{NoopFetcher, VfsDebug, VfsSnapshot}; use memofs::{InMemoryFs, VfsSnapshot};
use insta::assert_yaml_snapshot;
#[test] #[test]
fn csv_from_vfs() { fn csv_from_vfs() {
let mut vfs = Vfs::new(NoopFetcher); let mut imfs = InMemoryFs::new();
let file = VfsSnapshot::file( imfs.load_snapshot(
r#" "/foo.csv",
VfsSnapshot::file(
r#"
Key,Source,Context,Example,es Key,Source,Context,Example,es
Ack,Ack!,,An exclamation of despair,¡Ay!"#, Ack,Ack!,,An exclamation of despair,¡Ay!"#,
); ),
)
.unwrap();
vfs.debug_load_snapshot("/foo.csv", file); let mut vfs = Vfs::new(imfs);
let entry = vfs.get("/foo.csv").unwrap();
let instance_snapshot = let instance_snapshot =
SnapshotCsv::from_vfs(&InstanceContext::default(), &mut vfs, &entry) SnapshotCsv::from_vfs(&InstanceContext::default(), &mut vfs, Path::new("/foo.csv"))
.unwrap() .unwrap()
.unwrap(); .unwrap();
assert_yaml_snapshot!(instance_snapshot); insta::assert_yaml_snapshot!(instance_snapshot);
} }
#[test] #[test]
fn csv_with_meta() { fn csv_with_meta() {
let mut vfs = Vfs::new(NoopFetcher); let mut imfs = InMemoryFs::new();
let file = VfsSnapshot::file( imfs.load_snapshot(
r#" "/foo.csv",
VfsSnapshot::file(
r#"
Key,Source,Context,Example,es Key,Source,Context,Example,es
Ack,Ack!,,An exclamation of despair,¡Ay!"#, Ack,Ack!,,An exclamation of despair,¡Ay!"#,
); ),
let meta = VfsSnapshot::file(r#"{ "ignoreUnknownInstances": true }"#); )
.unwrap();
imfs.load_snapshot(
"/foo.meta.json",
VfsSnapshot::file(r#"{ "ignoreUnknownInstances": true }"#),
)
.unwrap();
vfs.debug_load_snapshot("/foo.csv", file); let mut vfs = Vfs::new(imfs);
vfs.debug_load_snapshot("/foo.meta.json", meta);
let entry = vfs.get("/foo.csv").unwrap();
let instance_snapshot = let instance_snapshot =
SnapshotCsv::from_vfs(&InstanceContext::default(), &mut vfs, &entry) SnapshotCsv::from_vfs(&InstanceContext::default(), &mut vfs, Path::new("/foo.csv"))
.unwrap() .unwrap()
.unwrap(); .unwrap();
assert_yaml_snapshot!(instance_snapshot);
} }
} }

View File

@@ -1,32 +1,27 @@
use std::collections::HashMap; use std::path::Path;
use rbx_dom_weak::{RbxId, RbxTree}; use memofs::{DirEntry, IoResultExt, Vfs};
use crate::{ use crate::snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot};
snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot},
vfs::{DirectorySnapshot, FsResultExt, Vfs, VfsEntry, VfsFetcher, VfsSnapshot},
};
use super::{ use super::{
error::SnapshotError, error::SnapshotError,
meta_file::DirectoryMetadata, meta_file::DirectoryMetadata,
middleware::{SnapshotFileResult, SnapshotInstanceResult, SnapshotMiddleware}, middleware::{SnapshotInstanceResult, SnapshotMiddleware},
snapshot_from_instance, snapshot_from_vfs, snapshot_from_vfs,
}; };
pub struct SnapshotDir; pub struct SnapshotDir;
impl SnapshotMiddleware for SnapshotDir { impl SnapshotMiddleware for SnapshotDir {
fn from_vfs<F: VfsFetcher>( fn from_vfs(context: &InstanceContext, vfs: &Vfs, path: &Path) -> SnapshotInstanceResult {
context: &InstanceContext, let meta = vfs.metadata(path)?;
vfs: &Vfs<F>,
entry: &VfsEntry, if meta.is_file() {
) -> SnapshotInstanceResult {
if entry.is_file() {
return Ok(None); return Ok(None);
} }
let passes_filter_rules = |child: &VfsEntry| { let passes_filter_rules = |child: &DirEntry| {
context context
.path_ignore_rules .path_ignore_rules
.iter() .iter()
@@ -35,31 +30,36 @@ impl SnapshotMiddleware for SnapshotDir {
let mut snapshot_children = Vec::new(); let mut snapshot_children = Vec::new();
for child in entry.children(vfs)?.into_iter().filter(passes_filter_rules) { for entry in vfs.read_dir(path)? {
if let Some(child_snapshot) = snapshot_from_vfs(context, vfs, &child)? { let entry = entry?;
if !passes_filter_rules(&entry) {
continue;
}
if let Some(child_snapshot) = snapshot_from_vfs(context, vfs, entry.path())? {
snapshot_children.push(child_snapshot); snapshot_children.push(child_snapshot);
} }
} }
let instance_name = entry let instance_name = path
.path()
.file_name() .file_name()
.expect("Could not extract file name") .expect("Could not extract file name")
.to_str() .to_str()
.ok_or_else(|| SnapshotError::file_name_bad_unicode(entry.path()))? .ok_or_else(|| SnapshotError::file_name_bad_unicode(path))?
.to_string(); .to_string();
let meta_path = entry.path().join("init.meta.json"); let meta_path = path.join("init.meta.json");
let relevant_paths = vec![ let relevant_paths = vec![
entry.path().to_path_buf(), path.to_path_buf(),
meta_path.clone(), meta_path.clone(),
// TODO: We shouldn't need to know about Lua existing in this // TODO: We shouldn't need to know about Lua existing in this
// middleware. Should we figure out a way for that function to add // middleware. Should we figure out a way for that function to add
// relevant paths to this middleware? // relevant paths to this middleware?
entry.path().join("init.lua"), path.join("init.lua"),
entry.path().join("init.server.lua"), path.join("init.server.lua"),
entry.path().join("init.client.lua"), path.join("init.client.lua"),
]; ];
let mut snapshot = InstanceSnapshot::new() let mut snapshot = InstanceSnapshot::new()
@@ -68,81 +68,61 @@ impl SnapshotMiddleware for SnapshotDir {
.children(snapshot_children) .children(snapshot_children)
.metadata( .metadata(
InstanceMetadata::new() InstanceMetadata::new()
.instigating_source(entry.path()) .instigating_source(path)
.relevant_paths(relevant_paths) .relevant_paths(relevant_paths)
.context(context), .context(context),
); );
if let Some(meta_entry) = vfs.get(meta_path).with_not_found()? { if let Some(meta_contents) = vfs.read(&meta_path).with_not_found()? {
let meta_contents = meta_entry.contents(vfs)?; let mut metadata = DirectoryMetadata::from_slice(&meta_contents, &meta_path)?;
let mut metadata = DirectoryMetadata::from_slice(&meta_contents);
metadata.apply_all(&mut snapshot); metadata.apply_all(&mut snapshot);
} }
Ok(Some(snapshot)) Ok(Some(snapshot))
} }
fn from_instance(tree: &RbxTree, id: RbxId) -> SnapshotFileResult {
let instance = tree.get_instance(id).unwrap();
if instance.class_name != "Folder" {
return None;
}
let mut children = HashMap::new();
for child_id in instance.get_children_ids() {
if let Some((name, child)) = snapshot_from_instance(tree, *child_id) {
children.insert(name, child);
}
}
let snapshot = VfsSnapshot::Directory(DirectorySnapshot { children });
Some((instance.name.clone(), snapshot))
}
} }
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;
use insta::assert_yaml_snapshot;
use maplit::hashmap; use maplit::hashmap;
use memofs::{InMemoryFs, VfsSnapshot};
use crate::vfs::{NoopFetcher, VfsDebug};
#[test] #[test]
fn empty_folder() { fn empty_folder() {
let mut vfs = Vfs::new(NoopFetcher); let mut imfs = InMemoryFs::new();
let dir = VfsSnapshot::dir::<String>(HashMap::new()); imfs.load_snapshot("/foo", VfsSnapshot::empty_dir())
.unwrap();
vfs.debug_load_snapshot("/foo", dir); let mut vfs = Vfs::new(imfs);
let entry = vfs.get("/foo").unwrap();
let instance_snapshot = let instance_snapshot =
SnapshotDir::from_vfs(&InstanceContext::default(), &mut vfs, &entry) SnapshotDir::from_vfs(&InstanceContext::default(), &mut vfs, Path::new("/foo"))
.unwrap() .unwrap()
.unwrap(); .unwrap();
assert_yaml_snapshot!(instance_snapshot); insta::assert_yaml_snapshot!(instance_snapshot);
} }
#[test] #[test]
fn folder_in_folder() { fn folder_in_folder() {
let mut vfs = Vfs::new(NoopFetcher); let mut imfs = InMemoryFs::new();
let dir = VfsSnapshot::dir(hashmap! { imfs.load_snapshot(
"Child" => VfsSnapshot::dir::<String>(HashMap::new()), "/foo",
}); VfsSnapshot::dir(hashmap! {
"Child" => VfsSnapshot::empty_dir(),
}),
)
.unwrap();
vfs.debug_load_snapshot("/foo", dir); let mut vfs = Vfs::new(imfs);
let entry = vfs.get("/foo").unwrap();
let instance_snapshot = let instance_snapshot =
SnapshotDir::from_vfs(&InstanceContext::default(), &mut vfs, &entry) SnapshotDir::from_vfs(&InstanceContext::default(), &mut vfs, Path::new("/foo"))
.unwrap() .unwrap()
.unwrap(); .unwrap();
assert_yaml_snapshot!(instance_snapshot); insta::assert_yaml_snapshot!(instance_snapshot);
} }
} }

View File

@@ -1,141 +1,79 @@
use std::{error::Error, fmt, io, path::PathBuf}; use std::{io, path::PathBuf};
use crate::vfs::FsError; use thiserror::Error;
#[derive(Debug)] #[derive(Debug, Error)]
pub struct SnapshotError { pub enum SnapshotError {
detail: SnapshotErrorDetail, #[error("file name had malformed Unicode")]
path: Option<PathBuf>, FileNameBadUnicode { path: PathBuf },
#[error("file had malformed Unicode contents")]
FileContentsBadUnicode {
source: std::str::Utf8Error,
path: PathBuf,
},
#[error("malformed project file")]
MalformedProject {
source: serde_json::Error,
path: PathBuf,
},
#[error("malformed .model.json file")]
MalformedModelJson {
source: serde_json::Error,
path: PathBuf,
},
#[error("malformed .meta.json file")]
MalformedMetaJson {
source: serde_json::Error,
path: PathBuf,
},
#[error(transparent)]
Io {
#[from]
source: io::Error,
},
} }
impl SnapshotError { impl SnapshotError {
pub fn new(detail: SnapshotErrorDetail, path: Option<impl Into<PathBuf>>) -> Self { pub(crate) fn file_name_bad_unicode(path: impl Into<PathBuf>) -> Self {
SnapshotError { Self::FileNameBadUnicode { path: path.into() }
detail,
path: path.map(Into::into),
}
}
pub(crate) fn wrap(inner: impl Into<SnapshotErrorDetail>, path: impl Into<PathBuf>) -> Self {
SnapshotError {
detail: inner.into(),
path: Some(path.into()),
}
}
pub(crate) fn file_did_not_exist(path: impl Into<PathBuf>) -> SnapshotError {
SnapshotError {
detail: SnapshotErrorDetail::FileDidNotExist,
path: Some(path.into()),
}
}
pub(crate) fn file_name_bad_unicode(path: impl Into<PathBuf>) -> SnapshotError {
SnapshotError {
detail: SnapshotErrorDetail::FileNameBadUnicode,
path: Some(path.into()),
}
} }
pub(crate) fn file_contents_bad_unicode( pub(crate) fn file_contents_bad_unicode(
inner: std::str::Utf8Error, source: std::str::Utf8Error,
path: impl Into<PathBuf>, path: impl Into<PathBuf>,
) -> SnapshotError { ) -> Self {
SnapshotError { Self::FileContentsBadUnicode {
detail: SnapshotErrorDetail::FileContentsBadUnicode { inner }, source,
path: Some(path.into()), path: path.into(),
} }
} }
pub(crate) fn malformed_project( pub(crate) fn malformed_project(source: serde_json::Error, path: impl Into<PathBuf>) -> Self {
inner: serde_json::Error, Self::MalformedProject {
source,
path: path.into(),
}
}
pub(crate) fn malformed_model_json(
source: serde_json::Error,
path: impl Into<PathBuf>, path: impl Into<PathBuf>,
) -> SnapshotError { ) -> Self {
SnapshotError { Self::MalformedModelJson {
detail: SnapshotErrorDetail::MalformedProject { inner }, source,
path: Some(path.into()), path: path.into(),
} }
} }
}
pub(crate) fn malformed_meta_json(source: serde_json::Error, path: impl Into<PathBuf>) -> Self {
impl Error for SnapshotError { Self::MalformedMetaJson {
fn source(&self) -> Option<&(dyn Error + 'static)> { source,
self.detail.source() path: path.into(),
}
}
impl fmt::Display for SnapshotError {
fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
match &self.path {
Some(path) => write!(formatter, "{} in path {}", self.detail, path.display()),
None => write!(formatter, "{}", self.detail),
}
}
}
impl From<FsError> for SnapshotError {
fn from(error: FsError) -> Self {
let (inner, path) = error.into_raw();
Self::new(inner.into(), Some(path))
}
}
impl From<rlua::Error> for SnapshotError {
fn from(error: rlua::Error) -> Self {
Self::new(error.into(), Option::<PathBuf>::None)
}
}
#[derive(Debug)]
pub enum SnapshotErrorDetail {
IoError { inner: io::Error },
Lua { inner: rlua::Error },
FileDidNotExist,
FileNameBadUnicode,
FileContentsBadUnicode { inner: std::str::Utf8Error },
MalformedProject { inner: serde_json::Error },
}
impl From<io::Error> for SnapshotErrorDetail {
fn from(inner: io::Error) -> Self {
SnapshotErrorDetail::IoError { inner }
}
}
impl From<rlua::Error> for SnapshotErrorDetail {
fn from(inner: rlua::Error) -> Self {
SnapshotErrorDetail::Lua { inner }
}
}
impl SnapshotErrorDetail {
fn source(&self) -> Option<&(dyn Error + 'static)> {
use self::SnapshotErrorDetail::*;
match self {
IoError { inner } => Some(inner),
Lua { inner } => Some(inner),
FileContentsBadUnicode { inner } => Some(inner),
MalformedProject { inner } => Some(inner),
_ => None,
}
}
}
impl fmt::Display for SnapshotErrorDetail {
fn fmt(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
use self::SnapshotErrorDetail::*;
match self {
IoError { inner } => write!(formatter, "I/O error: {}", inner),
Lua { inner } => write!(formatter, "{}", inner),
FileDidNotExist => write!(formatter, "file did not exist"),
FileNameBadUnicode => write!(formatter, "file name had malformed Unicode"),
FileContentsBadUnicode { inner } => {
write!(formatter, "file had malformed unicode: {}", inner)
}
MalformedProject { inner } => write!(formatter, "{}", inner),
} }
} }
} }

View File

@@ -1,15 +1,14 @@
use std::{borrow::Cow, collections::HashMap}; use std::{borrow::Cow, collections::HashMap, path::Path};
use memofs::Vfs;
use rbx_dom_weak::UnresolvedRbxValue; use rbx_dom_weak::UnresolvedRbxValue;
use rbx_reflection::try_resolve_value; use rbx_reflection::try_resolve_value;
use serde::Deserialize; use serde::Deserialize;
use crate::{ use crate::snapshot::{InstanceContext, InstanceSnapshot};
snapshot::{InstanceContext, InstanceSnapshot},
vfs::{Vfs, VfsEntry, VfsFetcher},
};
use super::{ use super::{
error::SnapshotError,
middleware::{SnapshotInstanceResult, SnapshotMiddleware}, middleware::{SnapshotInstanceResult, SnapshotMiddleware},
util::match_file_name, util::match_file_name,
}; };
@@ -17,28 +16,27 @@ use super::{
pub struct SnapshotJsonModel; pub struct SnapshotJsonModel;
impl SnapshotMiddleware for SnapshotJsonModel { impl SnapshotMiddleware for SnapshotJsonModel {
fn from_vfs<F: VfsFetcher>( fn from_vfs(context: &InstanceContext, vfs: &Vfs, path: &Path) -> SnapshotInstanceResult {
context: &InstanceContext, let meta = vfs.metadata(path)?;
vfs: &Vfs<F>,
entry: &VfsEntry, if meta.is_dir() {
) -> SnapshotInstanceResult {
if entry.is_directory() {
return Ok(None); return Ok(None);
} }
let instance_name = match match_file_name(entry.path(), ".model.json") { let instance_name = match match_file_name(path, ".model.json") {
Some(name) => name, Some(name) => name,
None => return Ok(None), None => return Ok(None),
}; };
let instance: JsonModel = let contents = vfs.read(path)?;
serde_json::from_slice(&entry.contents(vfs)?).expect("TODO: Handle serde_json errors"); let instance: JsonModel = serde_json::from_slice(&contents)
.map_err(|source| SnapshotError::malformed_model_json(source, path))?;
if let Some(json_name) = &instance.name { if let Some(json_name) = &instance.name {
if json_name != instance_name { if json_name != instance_name {
log::warn!( log::warn!(
"Name from JSON model did not match its file name: {}", "Name from JSON model did not match its file name: {}",
entry.path().display() path.display()
); );
log::warn!( log::warn!(
"In Rojo < alpha 14, this model is named \"{}\" (from its 'Name' property)", "In Rojo < alpha 14, this model is named \"{}\" (from its 'Name' property)",
@@ -56,8 +54,8 @@ impl SnapshotMiddleware for SnapshotJsonModel {
snapshot.metadata = snapshot snapshot.metadata = snapshot
.metadata .metadata
.instigating_source(entry.path()) .instigating_source(path)
.relevant_paths(vec![entry.path().to_path_buf()]) .relevant_paths(vec![path.to_path_buf()])
.context(context); .context(context);
Ok(Some(snapshot)) Ok(Some(snapshot))
@@ -137,39 +135,43 @@ impl JsonModelCore {
mod test { mod test {
use super::*; use super::*;
use insta::assert_yaml_snapshot; use memofs::{InMemoryFs, VfsSnapshot};
use crate::vfs::{NoopFetcher, VfsDebug, VfsSnapshot};
#[test] #[test]
fn model_from_vfs() { fn model_from_vfs() {
let mut vfs = Vfs::new(NoopFetcher); let mut imfs = InMemoryFs::new();
let file = VfsSnapshot::file( imfs.load_snapshot(
r#" "/foo.model.json",
{ VfsSnapshot::file(
"Name": "children", r#"
"ClassName": "IntValue", {
"Properties": { "Name": "children",
"Value": 5 "ClassName": "IntValue",
}, "Properties": {
"Children": [ "Value": 5
{ },
"Name": "The Child", "Children": [
"ClassName": "StringValue" {
} "Name": "The Child",
] "ClassName": "StringValue"
} }
"#, ]
); }
"#,
),
)
.unwrap();
vfs.debug_load_snapshot("/foo.model.json", file); let mut vfs = Vfs::new(imfs);
let entry = vfs.get("/foo.model.json").unwrap(); let instance_snapshot = SnapshotJsonModel::from_vfs(
let instance_snapshot = &InstanceContext::default(),
SnapshotJsonModel::from_vfs(&InstanceContext::default(), &mut vfs, &entry) &mut vfs,
.unwrap() Path::new("/foo.model.json"),
.unwrap(); )
.unwrap()
.unwrap();
assert_yaml_snapshot!(instance_snapshot); insta::assert_yaml_snapshot!(instance_snapshot);
} }
} }

View File

@@ -1,12 +1,10 @@
use std::str; use std::{path::Path, str};
use maplit::hashmap; use maplit::hashmap;
use memofs::{IoResultExt, Vfs};
use rbx_dom_weak::RbxValue; use rbx_dom_weak::RbxValue;
use crate::{ use crate::snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot};
snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot},
vfs::{FsResultExt, Vfs, VfsEntry, VfsFetcher},
};
use super::{ use super::{
dir::SnapshotDir, dir::SnapshotDir,
@@ -18,12 +16,8 @@ use super::{
pub struct SnapshotLua; pub struct SnapshotLua;
impl SnapshotMiddleware for SnapshotLua { impl SnapshotMiddleware for SnapshotLua {
fn from_vfs<F: VfsFetcher>( fn from_vfs(context: &InstanceContext, vfs: &Vfs, path: &Path) -> SnapshotInstanceResult {
context: &InstanceContext, let file_name = path.file_name().unwrap().to_string_lossy();
vfs: &Vfs<F>,
entry: &VfsEntry,
) -> SnapshotInstanceResult {
let file_name = entry.path().file_name().unwrap().to_string_lossy();
// These paths alter their parent instance, so we don't need to turn // These paths alter their parent instance, so we don't need to turn
// them into a script instance here. // them into a script instance here.
@@ -32,18 +26,20 @@ impl SnapshotMiddleware for SnapshotLua {
_ => {} _ => {}
} }
if entry.is_file() { let meta = vfs.metadata(path)?;
snapshot_lua_file(context, vfs, entry)
if meta.is_file() {
snapshot_lua_file(context, vfs, path)
} else { } else {
// At this point, our entry is definitely a directory! // At this point, our entry is definitely a directory!
if let Some(snapshot) = snapshot_init(context, vfs, entry, "init.lua")? { if let Some(snapshot) = snapshot_init(context, vfs, path, "init.lua")? {
// An `init.lua` file turns its parent into a ModuleScript // An `init.lua` file turns its parent into a ModuleScript
Ok(Some(snapshot)) Ok(Some(snapshot))
} else if let Some(snapshot) = snapshot_init(context, vfs, entry, "init.server.lua")? { } else if let Some(snapshot) = snapshot_init(context, vfs, path, "init.server.lua")? {
// An `init.server.lua` file turns its parent into a Script // An `init.server.lua` file turns its parent into a Script
Ok(Some(snapshot)) Ok(Some(snapshot))
} else if let Some(snapshot) = snapshot_init(context, vfs, entry, "init.client.lua")? { } else if let Some(snapshot) = snapshot_init(context, vfs, path, "init.client.lua")? {
// An `init.client.lua` file turns its parent into a LocalScript // An `init.client.lua` file turns its parent into a LocalScript
Ok(Some(snapshot)) Ok(Some(snapshot))
} else { } else {
@@ -54,12 +50,8 @@ impl SnapshotMiddleware for SnapshotLua {
} }
/// Core routine for turning Lua files into snapshots. /// Core routine for turning Lua files into snapshots.
fn snapshot_lua_file<F: VfsFetcher>( fn snapshot_lua_file(context: &InstanceContext, vfs: &Vfs, path: &Path) -> SnapshotInstanceResult {
context: &InstanceContext, let file_name = path.file_name().unwrap().to_string_lossy();
vfs: &Vfs<F>,
entry: &VfsEntry,
) -> SnapshotInstanceResult {
let file_name = entry.path().file_name().unwrap().to_string_lossy();
let (class_name, instance_name) = if let Some(name) = match_trailing(&file_name, ".server.lua") let (class_name, instance_name) = if let Some(name) = match_trailing(&file_name, ".server.lua")
{ {
@@ -72,15 +64,13 @@ fn snapshot_lua_file<F: VfsFetcher>(
return Ok(None); return Ok(None);
}; };
let contents = entry.contents(vfs)?; let contents = vfs.read(path)?;
let contents_str = str::from_utf8(&contents) let contents_str = str::from_utf8(&contents)
// TODO: Turn into error type // TODO: Turn into error type
.expect("File content was not valid UTF-8") .expect("File content was not valid UTF-8")
.to_string(); .to_string();
let meta_path = entry let meta_path = path.with_file_name(format!("{}.meta.json", instance_name));
.path()
.with_file_name(format!("{}.meta.json", instance_name));
let mut snapshot = InstanceSnapshot::new() let mut snapshot = InstanceSnapshot::new()
.name(instance_name) .name(instance_name)
@@ -92,14 +82,13 @@ fn snapshot_lua_file<F: VfsFetcher>(
}) })
.metadata( .metadata(
InstanceMetadata::new() InstanceMetadata::new()
.instigating_source(entry.path()) .instigating_source(path)
.relevant_paths(vec![entry.path().to_path_buf(), meta_path.clone()]) .relevant_paths(vec![path.to_path_buf(), meta_path.clone()])
.context(context), .context(context),
); );
if let Some(meta_entry) = vfs.get(meta_path).with_not_found()? { if let Some(meta_contents) = vfs.read(&meta_path).with_not_found()? {
let meta_contents = meta_entry.contents(vfs)?; let mut metadata = AdjacentMetadata::from_slice(&meta_contents, &meta_path)?;
let mut metadata = AdjacentMetadata::from_slice(&meta_contents);
metadata.apply_all(&mut snapshot); metadata.apply_all(&mut snapshot);
} }
@@ -111,17 +100,17 @@ fn snapshot_lua_file<F: VfsFetcher>(
/// ///
/// Scripts named `init.lua`, `init.server.lua`, or `init.client.lua` usurp /// Scripts named `init.lua`, `init.server.lua`, or `init.client.lua` usurp
/// their parents, which acts similarly to `__init__.py` from the Python world. /// their parents, which acts similarly to `__init__.py` from the Python world.
fn snapshot_init<F: VfsFetcher>( fn snapshot_init(
context: &InstanceContext, context: &InstanceContext,
vfs: &Vfs<F>, vfs: &Vfs,
folder_entry: &VfsEntry, folder_path: &Path,
init_name: &str, init_name: &str,
) -> SnapshotInstanceResult { ) -> SnapshotInstanceResult {
let init_path = folder_entry.path().join(init_name); let init_path = folder_path.join(init_name);
if let Some(init_entry) = vfs.get(init_path).with_not_found()? { if vfs.metadata(&init_path).with_not_found()?.is_some() {
if let Some(dir_snapshot) = SnapshotDir::from_vfs(context, vfs, folder_entry)? { if let Some(dir_snapshot) = SnapshotDir::from_vfs(context, vfs, folder_path)? {
if let Some(mut init_snapshot) = snapshot_lua_file(context, vfs, &init_entry)? { if let Some(mut init_snapshot) = snapshot_lua_file(context, vfs, &init_path)? {
if dir_snapshot.class_name != "Folder" { if dir_snapshot.class_name != "Folder" {
panic!( panic!(
"init.lua, init.server.lua, and init.client.lua can \ "init.lua, init.server.lua, and init.client.lua can \
@@ -146,149 +135,171 @@ fn snapshot_init<F: VfsFetcher>(
mod test { mod test {
use super::*; use super::*;
use insta::{assert_yaml_snapshot, with_settings}; use memofs::{InMemoryFs, VfsSnapshot};
use crate::vfs::{NoopFetcher, VfsDebug, VfsSnapshot};
#[test] #[test]
fn module_from_vfs() { fn module_from_vfs() {
let mut vfs = Vfs::new(NoopFetcher); let mut imfs = InMemoryFs::new();
let file = VfsSnapshot::file("Hello there!"); imfs.load_snapshot("/foo.lua", VfsSnapshot::file("Hello there!"))
.unwrap();
vfs.debug_load_snapshot("/foo.lua", file); let mut vfs = Vfs::new(imfs);
let entry = vfs.get("/foo.lua").unwrap();
let instance_snapshot = let instance_snapshot =
SnapshotLua::from_vfs(&InstanceContext::default(), &mut vfs, &entry) SnapshotLua::from_vfs(&InstanceContext::default(), &mut vfs, Path::new("/foo.lua"))
.unwrap() .unwrap()
.unwrap(); .unwrap();
assert_yaml_snapshot!(instance_snapshot); insta::assert_yaml_snapshot!(instance_snapshot);
} }
#[test] #[test]
fn server_from_vfs() { fn server_from_vfs() {
let mut vfs = Vfs::new(NoopFetcher); let mut imfs = InMemoryFs::new();
let file = VfsSnapshot::file("Hello there!"); imfs.load_snapshot("/foo.server.lua", VfsSnapshot::file("Hello there!"))
.unwrap();
vfs.debug_load_snapshot("/foo.server.lua", file); let mut vfs = Vfs::new(imfs);
let entry = vfs.get("/foo.server.lua").unwrap(); let instance_snapshot = SnapshotLua::from_vfs(
let instance_snapshot = &InstanceContext::default(),
SnapshotLua::from_vfs(&InstanceContext::default(), &mut vfs, &entry) &mut vfs,
.unwrap() Path::new("/foo.server.lua"),
.unwrap(); )
.unwrap()
.unwrap();
assert_yaml_snapshot!(instance_snapshot); insta::assert_yaml_snapshot!(instance_snapshot);
} }
#[test] #[test]
fn client_from_vfs() { fn client_from_vfs() {
let mut vfs = Vfs::new(NoopFetcher); let mut imfs = InMemoryFs::new();
let file = VfsSnapshot::file("Hello there!"); imfs.load_snapshot("/foo.client.lua", VfsSnapshot::file("Hello there!"))
.unwrap();
vfs.debug_load_snapshot("/foo.client.lua", file); let mut vfs = Vfs::new(imfs);
let entry = vfs.get("/foo.client.lua").unwrap(); let instance_snapshot = SnapshotLua::from_vfs(
let instance_snapshot = &InstanceContext::default(),
SnapshotLua::from_vfs(&InstanceContext::default(), &mut vfs, &entry) &mut vfs,
.unwrap() Path::new("/foo.client.lua"),
.unwrap(); )
.unwrap()
.unwrap();
assert_yaml_snapshot!(instance_snapshot); insta::assert_yaml_snapshot!(instance_snapshot);
} }
#[test] #[test]
fn init_module_from_vfs() { fn init_module_from_vfs() {
let mut vfs = Vfs::new(NoopFetcher); let mut imfs = InMemoryFs::new();
let dir = VfsSnapshot::dir(hashmap! { imfs.load_snapshot(
"init.lua" => VfsSnapshot::file("Hello!"), "/root",
}); VfsSnapshot::dir(hashmap! {
"init.lua" => VfsSnapshot::file("Hello!"),
}),
)
.unwrap();
vfs.debug_load_snapshot("/root", dir); let mut vfs = Vfs::new(imfs);
let entry = vfs.get("/root").unwrap();
let instance_snapshot = let instance_snapshot =
SnapshotLua::from_vfs(&InstanceContext::default(), &mut vfs, &entry) SnapshotLua::from_vfs(&InstanceContext::default(), &mut vfs, Path::new("/root"))
.unwrap() .unwrap()
.unwrap(); .unwrap();
assert_yaml_snapshot!(instance_snapshot); insta::assert_yaml_snapshot!(instance_snapshot);
} }
#[test] #[test]
fn module_with_meta() { fn module_with_meta() {
let mut vfs = Vfs::new(NoopFetcher); let mut imfs = InMemoryFs::new();
let file = VfsSnapshot::file("Hello there!"); imfs.load_snapshot("/foo.lua", VfsSnapshot::file("Hello there!"))
let meta = VfsSnapshot::file( .unwrap();
r#" imfs.load_snapshot(
{ "/foo.meta.json",
"ignoreUnknownInstances": true VfsSnapshot::file(
} r#"
"#, {
); "ignoreUnknownInstances": true
}
"#,
),
)
.unwrap();
vfs.debug_load_snapshot("/foo.lua", file); let mut vfs = Vfs::new(imfs);
vfs.debug_load_snapshot("/foo.meta.json", meta);
let entry = vfs.get("/foo.lua").unwrap();
let instance_snapshot = let instance_snapshot =
SnapshotLua::from_vfs(&InstanceContext::default(), &mut vfs, &entry) SnapshotLua::from_vfs(&InstanceContext::default(), &mut vfs, Path::new("/foo.lua"))
.unwrap() .unwrap()
.unwrap(); .unwrap();
assert_yaml_snapshot!(instance_snapshot); insta::assert_yaml_snapshot!(instance_snapshot);
} }
#[test] #[test]
fn script_with_meta() { fn script_with_meta() {
let mut vfs = Vfs::new(NoopFetcher); let mut imfs = InMemoryFs::new();
let file = VfsSnapshot::file("Hello there!"); imfs.load_snapshot("/foo.server.lua", VfsSnapshot::file("Hello there!"))
let meta = VfsSnapshot::file( .unwrap();
r#" imfs.load_snapshot(
{ "/foo.meta.json",
"ignoreUnknownInstances": true VfsSnapshot::file(
} r#"
"#, {
); "ignoreUnknownInstances": true
}
"#,
),
)
.unwrap();
vfs.debug_load_snapshot("/foo.server.lua", file); let mut vfs = Vfs::new(imfs);
vfs.debug_load_snapshot("/foo.meta.json", meta);
let entry = vfs.get("/foo.server.lua").unwrap(); let instance_snapshot = SnapshotLua::from_vfs(
let instance_snapshot = &InstanceContext::default(),
SnapshotLua::from_vfs(&InstanceContext::default(), &mut vfs, &entry) &mut vfs,
.unwrap() Path::new("/foo.server.lua"),
.unwrap(); )
.unwrap()
.unwrap();
assert_yaml_snapshot!(instance_snapshot); insta::assert_yaml_snapshot!(instance_snapshot);
} }
#[test] #[test]
fn script_disabled() { fn script_disabled() {
let mut vfs = Vfs::new(NoopFetcher); let mut imfs = InMemoryFs::new();
let file = VfsSnapshot::file("Hello there!"); imfs.load_snapshot("/bar.server.lua", VfsSnapshot::file("Hello there!"))
let meta = VfsSnapshot::file( .unwrap();
r#" imfs.load_snapshot(
{ "/bar.meta.json",
"properties": { VfsSnapshot::file(
"Disabled": true r#"
} {
} "properties": {
"#, "Disabled": true
); }
}
"#,
),
)
.unwrap();
vfs.debug_load_snapshot("/bar.server.lua", file); let mut vfs = Vfs::new(imfs);
vfs.debug_load_snapshot("/bar.meta.json", meta);
let entry = vfs.get("/bar.server.lua").unwrap(); let instance_snapshot = SnapshotLua::from_vfs(
let instance_snapshot = &InstanceContext::default(),
SnapshotLua::from_vfs(&InstanceContext::default(), &mut vfs, &entry) &mut vfs,
.unwrap() Path::new("/bar.server.lua"),
.unwrap(); )
.unwrap()
.unwrap();
with_settings!({ sort_maps => true }, { insta::with_settings!({ sort_maps => true }, {
assert_yaml_snapshot!(instance_snapshot); insta::assert_yaml_snapshot!(instance_snapshot);
}); });
} }
} }

View File

@@ -1,4 +1,4 @@
use std::{borrow::Cow, collections::HashMap}; use std::{borrow::Cow, collections::HashMap, path::Path};
use rbx_dom_weak::UnresolvedRbxValue; use rbx_dom_weak::UnresolvedRbxValue;
use rbx_reflection::try_resolve_value; use rbx_reflection::try_resolve_value;
@@ -6,6 +6,8 @@ use serde::{Deserialize, Serialize};
use crate::snapshot::InstanceSnapshot; use crate::snapshot::InstanceSnapshot;
use super::error::SnapshotError;
/// Represents metadata in a sibling file with the same basename. /// Represents metadata in a sibling file with the same basename.
/// ///
/// As an example, hello.meta.json next to hello.lua would allow assigning /// As an example, hello.meta.json next to hello.lua would allow assigning
@@ -21,10 +23,9 @@ pub struct AdjacentMetadata {
} }
impl AdjacentMetadata { impl AdjacentMetadata {
pub fn from_slice(slice: &[u8]) -> Self { pub fn from_slice(slice: &[u8], path: &Path) -> Result<Self, SnapshotError> {
serde_json::from_slice(slice) serde_json::from_slice(slice)
// TODO: Turn into error type .map_err(|source| SnapshotError::malformed_meta_json(source, path))
.expect(".meta.json file was malformed")
} }
pub fn apply_ignore_unknown_instances(&mut self, snapshot: &mut InstanceSnapshot) { pub fn apply_ignore_unknown_instances(&mut self, snapshot: &mut InstanceSnapshot) {
@@ -74,10 +75,9 @@ pub struct DirectoryMetadata {
} }
impl DirectoryMetadata { impl DirectoryMetadata {
pub fn from_slice(slice: &[u8]) -> Self { pub fn from_slice(slice: &[u8], path: &Path) -> Result<Self, SnapshotError> {
serde_json::from_slice(slice) serde_json::from_slice(slice)
// TODO: Turn into error type .map_err(|source| SnapshotError::malformed_meta_json(source, path))
.expect("init.meta.json file was malformed")
} }
pub fn apply_all(&mut self, snapshot: &mut InstanceSnapshot) { pub fn apply_all(&mut self, snapshot: &mut InstanceSnapshot) {

View File

@@ -1,23 +1,13 @@
use rbx_dom_weak::{RbxId, RbxTree}; use std::path::Path;
use crate::{ use memofs::Vfs;
snapshot::{InstanceContext, InstanceSnapshot},
vfs::{Vfs, VfsEntry, VfsFetcher, VfsSnapshot}, use crate::snapshot::{InstanceContext, InstanceSnapshot};
};
use super::error::SnapshotError; use super::error::SnapshotError;
pub type SnapshotInstanceResult = Result<Option<InstanceSnapshot>, SnapshotError>; pub type SnapshotInstanceResult = Result<Option<InstanceSnapshot>, SnapshotError>;
pub type SnapshotFileResult = Option<(String, VfsSnapshot)>;
pub trait SnapshotMiddleware { pub trait SnapshotMiddleware {
fn from_vfs<F: VfsFetcher>( fn from_vfs(context: &InstanceContext, vfs: &Vfs, path: &Path) -> SnapshotInstanceResult;
context: &InstanceContext,
vfs: &Vfs<F>,
entry: &VfsEntry,
) -> SnapshotInstanceResult;
fn from_instance(_tree: &RbxTree, _id: RbxId) -> SnapshotFileResult {
None
}
} }

View File

@@ -15,46 +15,37 @@ mod rbxlx;
mod rbxm; mod rbxm;
mod rbxmx; mod rbxmx;
mod txt; mod txt;
mod user_plugins;
mod util; mod util;
pub use self::error::*; pub use self::error::*;
use rbx_dom_weak::{RbxId, RbxTree}; use std::path::Path;
use memofs::Vfs;
use self::middleware::{SnapshotInstanceResult, SnapshotMiddleware};
use self::{ use self::{
csv::SnapshotCsv, csv::SnapshotCsv, dir::SnapshotDir, json_model::SnapshotJsonModel, lua::SnapshotLua,
dir::SnapshotDir, project::SnapshotProject, rbxlx::SnapshotRbxlx, rbxm::SnapshotRbxm, rbxmx::SnapshotRbxmx,
json_model::SnapshotJsonModel,
lua::SnapshotLua,
middleware::{SnapshotFileResult, SnapshotInstanceResult, SnapshotMiddleware},
project::SnapshotProject,
rbxlx::SnapshotRbxlx,
rbxm::SnapshotRbxm,
rbxmx::SnapshotRbxmx,
txt::SnapshotTxt, txt::SnapshotTxt,
user_plugins::SnapshotUserPlugins,
};
use crate::{
snapshot::InstanceContext,
vfs::{Vfs, VfsEntry, VfsFetcher},
}; };
use crate::snapshot::InstanceContext;
pub use self::project::snapshot_project_node; pub use self::project::snapshot_project_node;
macro_rules! middlewares { macro_rules! middlewares {
( $($middleware: ident,)* ) => { ( $($middleware: ident,)* ) => {
/// Generates a snapshot of instances from the given VfsEntry. /// Generates a snapshot of instances from the given path.
pub fn snapshot_from_vfs<F: VfsFetcher>( pub fn snapshot_from_vfs(
context: &InstanceContext, context: &InstanceContext,
vfs: &Vfs<F>, vfs: &Vfs,
entry: &VfsEntry, path: &Path,
) -> SnapshotInstanceResult { ) -> SnapshotInstanceResult {
$( $(
log::trace!("trying middleware {} on {}", stringify!($middleware), entry.path().display()); log::trace!("trying middleware {} on {}", stringify!($middleware), path.display());
if let Some(snapshot) = $middleware::from_vfs(context, vfs, entry)? { if let Some(snapshot) = $middleware::from_vfs(context, vfs, path)? {
log::trace!("middleware {} success on {}", stringify!($middleware), entry.path().display()); log::trace!("middleware {} success on {}", stringify!($middleware), path.display());
return Ok(Some(snapshot)); return Ok(Some(snapshot));
} }
)* )*
@@ -62,24 +53,11 @@ macro_rules! middlewares {
log::trace!("no middleware returned Ok(Some)"); log::trace!("no middleware returned Ok(Some)");
Ok(None) Ok(None)
} }
/// Generates an in-memory filesystem snapshot of the given Roblox
/// instance.
pub fn snapshot_from_instance(tree: &RbxTree, id: RbxId) -> SnapshotFileResult {
$(
if let Some(result) = $middleware::from_instance(tree, id) {
return Some(result);
}
)*
None
}
}; };
} }
middlewares! { middlewares! {
SnapshotProject, SnapshotProject,
SnapshotUserPlugins,
SnapshotJsonModel, SnapshotJsonModel,
SnapshotRbxlx, SnapshotRbxlx,
SnapshotRbxmx, SnapshotRbxmx,

View File

@@ -1,5 +1,6 @@
use std::{borrow::Cow, collections::HashMap, path::Path}; use std::{borrow::Cow, collections::HashMap, path::Path};
use memofs::{IoResultExt, Vfs};
use rbx_reflection::try_resolve_value; use rbx_reflection::try_resolve_value;
use crate::{ use crate::{
@@ -7,7 +8,6 @@ use crate::{
snapshot::{ snapshot::{
InstanceContext, InstanceMetadata, InstanceSnapshot, InstigatingSource, PathIgnoreRule, InstanceContext, InstanceMetadata, InstanceSnapshot, InstigatingSource, PathIgnoreRule,
}, },
vfs::{FsResultExt, Vfs, VfsEntry, VfsFetcher},
}; };
use super::{ use super::{
@@ -22,30 +22,28 @@ use super::{
pub struct SnapshotProject; pub struct SnapshotProject;
impl SnapshotMiddleware for SnapshotProject { impl SnapshotMiddleware for SnapshotProject {
fn from_vfs<F: VfsFetcher>( fn from_vfs(context: &InstanceContext, vfs: &Vfs, path: &Path) -> SnapshotInstanceResult {
context: &InstanceContext, let meta = vfs.metadata(path)?;
vfs: &Vfs<F>,
entry: &VfsEntry,
) -> SnapshotInstanceResult {
if entry.is_directory() {
let project_path = entry.path().join("default.project.json");
match vfs.get(project_path).with_not_found()? { if meta.is_dir() {
let project_path = path.join("default.project.json");
match vfs.metadata(&project_path).with_not_found()? {
// TODO: Do we need to muck with the relevant paths if we're a // TODO: Do we need to muck with the relevant paths if we're a
// project file within a folder? Should the folder path be the // project file within a folder? Should the folder path be the
// relevant path instead of the project file path? // relevant path instead of the project file path?
Some(entry) => return SnapshotProject::from_vfs(context, vfs, &entry), Some(_meta) => return SnapshotProject::from_vfs(context, vfs, &project_path),
None => return Ok(None), None => return Ok(None),
} }
} }
if !entry.path().to_string_lossy().ends_with(".project.json") { if !path.to_string_lossy().ends_with(".project.json") {
// This isn't a project file, so it's not our job. // This isn't a project file, so it's not our job.
return Ok(None); return Ok(None);
} }
let project = Project::load_from_slice(&entry.contents(vfs)?, entry.path()) let project = Project::load_from_slice(&vfs.read(path)?, path)
.map_err(|err| SnapshotError::malformed_project(err, entry.path()))?; .map_err(|err| SnapshotError::malformed_project(err, path))?;
let mut context = context.clone(); let mut context = context.clone();
@@ -75,7 +73,7 @@ impl SnapshotMiddleware for SnapshotProject {
// relevant path -> snapshot path mapping per instance, we pick the more // relevant path -> snapshot path mapping per instance, we pick the more
// conservative approach of snapshotting the project file if any // conservative approach of snapshotting the project file if any
// relevant paths changed. // relevant paths changed.
snapshot.metadata.instigating_source = Some(entry.path().to_path_buf().into()); snapshot.metadata.instigating_source = Some(path.to_path_buf().into());
// Mark this snapshot (the root node of the project file) as being // Mark this snapshot (the root node of the project file) as being
// related to the project file. // related to the project file.
@@ -83,21 +81,18 @@ impl SnapshotMiddleware for SnapshotProject {
// We SHOULD NOT mark the project file as a relevant path for any // We SHOULD NOT mark the project file as a relevant path for any
// nodes that aren't roots. They'll be updated as part of the project // nodes that aren't roots. They'll be updated as part of the project
// file being updated. // file being updated.
snapshot snapshot.metadata.relevant_paths.push(path.to_path_buf());
.metadata
.relevant_paths
.push(entry.path().to_path_buf());
Ok(Some(snapshot)) Ok(Some(snapshot))
} }
} }
pub fn snapshot_project_node<F: VfsFetcher>( pub fn snapshot_project_node(
context: &InstanceContext, context: &InstanceContext,
project_folder: &Path, project_folder: &Path,
instance_name: &str, instance_name: &str,
node: &ProjectNode, node: &ProjectNode,
vfs: &Vfs<F>, vfs: &Vfs,
) -> SnapshotInstanceResult { ) -> SnapshotInstanceResult {
let name = Cow::Owned(instance_name.to_owned()); let name = Cow::Owned(instance_name.to_owned());
let mut class_name = node let mut class_name = node
@@ -117,9 +112,7 @@ pub fn snapshot_project_node<F: VfsFetcher>(
Cow::Borrowed(path) Cow::Borrowed(path)
}; };
let entry = vfs.get(path.as_path())?; if let Some(snapshot) = snapshot_from_vfs(context, vfs, &path)? {
if let Some(snapshot) = snapshot_from_vfs(context, vfs, &entry)? {
// If a class name was already specified, then it'll override the // If a class name was already specified, then it'll override the
// class name of this snapshot ONLY if it's a Folder. // class name of this snapshot ONLY if it's a Folder.
// //
@@ -217,259 +210,284 @@ pub fn snapshot_project_node<F: VfsFetcher>(
mod test { mod test {
use super::*; use super::*;
use insta::assert_yaml_snapshot;
use maplit::hashmap; use maplit::hashmap;
use memofs::{InMemoryFs, VfsSnapshot};
use crate::vfs::{NoopFetcher, VfsDebug, VfsSnapshot};
#[test] #[test]
fn project_from_folder() { fn project_from_folder() {
let _ = env_logger::try_init(); let _ = env_logger::try_init();
let mut vfs = Vfs::new(NoopFetcher); let mut imfs = InMemoryFs::new();
let dir = VfsSnapshot::dir(hashmap! { imfs.load_snapshot(
"default.project.json" => VfsSnapshot::file(r#" "/foo",
{ VfsSnapshot::dir(hashmap! {
"name": "indirect-project", "default.project.json" => VfsSnapshot::file(r#"
"tree": { {
"$className": "Folder" "name": "indirect-project",
"tree": {
"$className": "Folder"
}
} }
} "#),
"#), }),
}); )
.unwrap();
vfs.debug_load_snapshot("/foo", dir); let mut vfs = Vfs::new(imfs);
let entry = vfs.get("/foo").unwrap();
let instance_snapshot = let instance_snapshot =
SnapshotProject::from_vfs(&InstanceContext::default(), &mut vfs, &entry) SnapshotProject::from_vfs(&InstanceContext::default(), &mut vfs, Path::new("/foo"))
.expect("snapshot error") .expect("snapshot error")
.expect("snapshot returned no instances"); .expect("snapshot returned no instances");
assert_yaml_snapshot!(instance_snapshot); insta::assert_yaml_snapshot!(instance_snapshot);
} }
#[test] #[test]
fn project_from_direct_file() { fn project_from_direct_file() {
let _ = env_logger::try_init(); let _ = env_logger::try_init();
let mut vfs = Vfs::new(NoopFetcher); let mut imfs = InMemoryFs::new();
let dir = VfsSnapshot::dir(hashmap! { imfs.load_snapshot(
"hello.project.json" => VfsSnapshot::file(r#" "/foo",
{ VfsSnapshot::dir(hashmap! {
"name": "direct-project", "hello.project.json" => VfsSnapshot::file(r#"
"tree": { {
"$className": "Model" "name": "direct-project",
"tree": {
"$className": "Model"
}
} }
} "#),
"#), }),
}); )
.unwrap();
vfs.debug_load_snapshot("/foo", dir); let mut vfs = Vfs::new(imfs);
let entry = vfs.get("/foo/hello.project.json").unwrap(); let instance_snapshot = SnapshotProject::from_vfs(
let instance_snapshot = &InstanceContext::default(),
SnapshotProject::from_vfs(&InstanceContext::default(), &mut vfs, &entry) &mut vfs,
.expect("snapshot error") Path::new("/foo/hello.project.json"),
.expect("snapshot returned no instances"); )
.expect("snapshot error")
.expect("snapshot returned no instances");
assert_yaml_snapshot!(instance_snapshot); insta::assert_yaml_snapshot!(instance_snapshot);
} }
#[test] #[test]
fn project_with_resolved_properties() { fn project_with_resolved_properties() {
let _ = env_logger::try_init(); let _ = env_logger::try_init();
let mut vfs = Vfs::new(NoopFetcher); let mut imfs = InMemoryFs::new();
let dir = VfsSnapshot::dir(hashmap! { imfs.load_snapshot(
"default.project.json" => VfsSnapshot::file(r#" "/foo",
{ VfsSnapshot::dir(hashmap! {
"name": "resolved-properties", "default.project.json" => VfsSnapshot::file(r#"
"tree": { {
"$className": "StringValue", "name": "resolved-properties",
"$properties": { "tree": {
"Value": { "$className": "StringValue",
"Type": "String", "$properties": {
"Value": "Hello, world!" "Value": {
"Type": "String",
"Value": "Hello, world!"
}
} }
} }
} }
} "#),
"#), }),
}); )
.unwrap();
vfs.debug_load_snapshot("/foo", dir); let mut vfs = Vfs::new(imfs);
let entry = vfs.get("/foo").unwrap();
let instance_snapshot = let instance_snapshot =
SnapshotProject::from_vfs(&InstanceContext::default(), &mut vfs, &entry) SnapshotProject::from_vfs(&InstanceContext::default(), &mut vfs, Path::new("/foo"))
.expect("snapshot error") .expect("snapshot error")
.expect("snapshot returned no instances"); .expect("snapshot returned no instances");
assert_yaml_snapshot!(instance_snapshot); insta::assert_yaml_snapshot!(instance_snapshot);
} }
#[test] #[test]
fn project_with_unresolved_properties() { fn project_with_unresolved_properties() {
let _ = env_logger::try_init(); let _ = env_logger::try_init();
let mut vfs = Vfs::new(NoopFetcher); let mut imfs = InMemoryFs::new();
let dir = VfsSnapshot::dir(hashmap! { imfs.load_snapshot(
"default.project.json" => VfsSnapshot::file(r#" "/foo",
{ VfsSnapshot::dir(hashmap! {
"name": "unresolved-properties", "default.project.json" => VfsSnapshot::file(r#"
"tree": { {
"$className": "StringValue", "name": "unresolved-properties",
"$properties": { "tree": {
"Value": "Hi!" "$className": "StringValue",
"$properties": {
"Value": "Hi!"
}
} }
} }
} "#),
"#), }),
}); )
.unwrap();
vfs.debug_load_snapshot("/foo", dir); let mut vfs = Vfs::new(imfs);
let entry = vfs.get("/foo").unwrap();
let instance_snapshot = let instance_snapshot =
SnapshotProject::from_vfs(&InstanceContext::default(), &mut vfs, &entry) SnapshotProject::from_vfs(&InstanceContext::default(), &mut vfs, Path::new("/foo"))
.expect("snapshot error") .expect("snapshot error")
.expect("snapshot returned no instances"); .expect("snapshot returned no instances");
assert_yaml_snapshot!(instance_snapshot); insta::assert_yaml_snapshot!(instance_snapshot);
} }
#[test] #[test]
fn project_with_children() { fn project_with_children() {
let _ = env_logger::try_init(); let _ = env_logger::try_init();
let mut vfs = Vfs::new(NoopFetcher); let mut imfs = InMemoryFs::new();
let dir = VfsSnapshot::dir(hashmap! { imfs.load_snapshot(
"default.project.json" => VfsSnapshot::file(r#" "/foo",
{ VfsSnapshot::dir(hashmap! {
"name": "children", "default.project.json" => VfsSnapshot::file(r#"
"tree": { {
"$className": "Folder", "name": "children",
"tree": {
"$className": "Folder",
"Child": { "Child": {
"$className": "Model" "$className": "Model"
}
} }
} }
} "#),
"#), }),
}); )
.unwrap();
vfs.debug_load_snapshot("/foo", dir); let mut vfs = Vfs::new(imfs);
let entry = vfs.get("/foo").unwrap();
let instance_snapshot = let instance_snapshot =
SnapshotProject::from_vfs(&InstanceContext::default(), &mut vfs, &entry) SnapshotProject::from_vfs(&InstanceContext::default(), &mut vfs, Path::new("/foo"))
.expect("snapshot error") .expect("snapshot error")
.expect("snapshot returned no instances"); .expect("snapshot returned no instances");
assert_yaml_snapshot!(instance_snapshot); insta::assert_yaml_snapshot!(instance_snapshot);
} }
#[test] #[test]
fn project_with_path_to_txt() { fn project_with_path_to_txt() {
let _ = env_logger::try_init(); let _ = env_logger::try_init();
let mut vfs = Vfs::new(NoopFetcher); let mut imfs = InMemoryFs::new();
let dir = VfsSnapshot::dir(hashmap! { imfs.load_snapshot(
"default.project.json" => VfsSnapshot::file(r#" "/foo",
{ VfsSnapshot::dir(hashmap! {
"name": "path-project", "default.project.json" => VfsSnapshot::file(r#"
"tree": { {
"$path": "other.txt" "name": "path-project",
"tree": {
"$path": "other.txt"
}
} }
} "#),
"#), "other.txt" => VfsSnapshot::file("Hello, world!"),
"other.txt" => VfsSnapshot::file("Hello, world!"), }),
}); )
.unwrap();
vfs.debug_load_snapshot("/foo", dir); let mut vfs = Vfs::new(imfs);
let entry = vfs.get("/foo").unwrap();
let instance_snapshot = let instance_snapshot =
SnapshotProject::from_vfs(&InstanceContext::default(), &mut vfs, &entry) SnapshotProject::from_vfs(&InstanceContext::default(), &mut vfs, Path::new("/foo"))
.expect("snapshot error") .expect("snapshot error")
.expect("snapshot returned no instances"); .expect("snapshot returned no instances");
assert_yaml_snapshot!(instance_snapshot); insta::assert_yaml_snapshot!(instance_snapshot);
} }
#[test] #[test]
fn project_with_path_to_project() { fn project_with_path_to_project() {
let _ = env_logger::try_init(); let _ = env_logger::try_init();
let mut vfs = Vfs::new(NoopFetcher); let mut imfs = InMemoryFs::new();
let dir = VfsSnapshot::dir(hashmap! { imfs.load_snapshot(
"default.project.json" => VfsSnapshot::file(r#" "/foo",
{ VfsSnapshot::dir(hashmap! {
"name": "path-project", "default.project.json" => VfsSnapshot::file(r#"
"tree": { {
"$path": "other.project.json" "name": "path-project",
"tree": {
"$path": "other.project.json"
}
} }
} "#),
"#), "other.project.json" => VfsSnapshot::file(r#"
"other.project.json" => VfsSnapshot::file(r#" {
{ "name": "other-project",
"name": "other-project", "tree": {
"tree": { "$className": "Model"
"$className": "Model" }
} }
} "#),
"#), }),
}); )
.unwrap();
vfs.debug_load_snapshot("/foo", dir); let mut vfs = Vfs::new(imfs);
let entry = vfs.get("/foo").unwrap();
let instance_snapshot = let instance_snapshot =
SnapshotProject::from_vfs(&InstanceContext::default(), &mut vfs, &entry) SnapshotProject::from_vfs(&InstanceContext::default(), &mut vfs, Path::new("/foo"))
.expect("snapshot error") .expect("snapshot error")
.expect("snapshot returned no instances"); .expect("snapshot returned no instances");
assert_yaml_snapshot!(instance_snapshot); insta::assert_yaml_snapshot!(instance_snapshot);
} }
#[test] #[test]
fn project_with_path_to_project_with_children() { fn project_with_path_to_project_with_children() {
let _ = env_logger::try_init(); let _ = env_logger::try_init();
let mut vfs = Vfs::new(NoopFetcher); let mut imfs = InMemoryFs::new();
let dir = VfsSnapshot::dir(hashmap! { imfs.load_snapshot(
"default.project.json" => VfsSnapshot::file(r#" "/foo",
{ VfsSnapshot::dir(hashmap! {
"name": "path-child-project", "default.project.json" => VfsSnapshot::file(r#"
"tree": { {
"$path": "other.project.json" "name": "path-child-project",
} "tree": {
} "$path": "other.project.json"
"#),
"other.project.json" => VfsSnapshot::file(r#"
{
"name": "other-project",
"tree": {
"$className": "Folder",
"SomeChild": {
"$className": "Model"
} }
} }
} "#),
"#), "other.project.json" => VfsSnapshot::file(r#"
}); {
"name": "other-project",
"tree": {
"$className": "Folder",
vfs.debug_load_snapshot("/foo", dir); "SomeChild": {
"$className": "Model"
}
}
}
"#),
}),
)
.unwrap();
let mut vfs = Vfs::new(imfs);
let entry = vfs.get("/foo").unwrap();
let instance_snapshot = let instance_snapshot =
SnapshotProject::from_vfs(&InstanceContext::default(), &mut vfs, &entry) SnapshotProject::from_vfs(&InstanceContext::default(), &mut vfs, Path::new("/foo"))
.expect("snapshot error") .expect("snapshot error")
.expect("snapshot returned no instances"); .expect("snapshot returned no instances");
assert_yaml_snapshot!(instance_snapshot); insta::assert_yaml_snapshot!(instance_snapshot);
} }
/// Ensures that if a property is defined both in the resulting instance /// Ensures that if a property is defined both in the resulting instance
@@ -479,40 +497,43 @@ mod test {
fn project_path_property_overrides() { fn project_path_property_overrides() {
let _ = env_logger::try_init(); let _ = env_logger::try_init();
let mut vfs = Vfs::new(NoopFetcher); let mut imfs = InMemoryFs::new();
let dir = VfsSnapshot::dir(hashmap! { imfs.load_snapshot(
"default.project.json" => VfsSnapshot::file(r#" "/foo",
{ VfsSnapshot::dir(hashmap! {
"name": "path-property-override", "default.project.json" => VfsSnapshot::file(r#"
"tree": { {
"$path": "other.project.json", "name": "path-property-override",
"$properties": { "tree": {
"Value": "Changed" "$path": "other.project.json",
"$properties": {
"Value": "Changed"
}
} }
} }
} "#),
"#), "other.project.json" => VfsSnapshot::file(r#"
"other.project.json" => VfsSnapshot::file(r#" {
{ "name": "other-project",
"name": "other-project", "tree": {
"tree": { "$className": "StringValue",
"$className": "StringValue", "$properties": {
"$properties": { "Value": "Original"
"Value": "Original" }
} }
} }
} "#),
"#), }),
}); )
.unwrap();
vfs.debug_load_snapshot("/foo", dir); let mut vfs = Vfs::new(imfs);
let entry = vfs.get("/foo").unwrap();
let instance_snapshot = let instance_snapshot =
SnapshotProject::from_vfs(&InstanceContext::default(), &mut vfs, &entry) SnapshotProject::from_vfs(&InstanceContext::default(), &mut vfs, Path::new("/foo"))
.expect("snapshot error") .expect("snapshot error")
.expect("snapshot returned no instances"); .expect("snapshot returned no instances");
assert_yaml_snapshot!(instance_snapshot); insta::assert_yaml_snapshot!(instance_snapshot);
} }
} }

View File

@@ -1,7 +1,8 @@
use crate::{ use std::path::Path;
snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot},
vfs::{Vfs, VfsEntry, VfsFetcher}, use memofs::Vfs;
};
use crate::snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot};
use super::{ use super::{
middleware::{SnapshotInstanceResult, SnapshotMiddleware}, middleware::{SnapshotInstanceResult, SnapshotMiddleware},
@@ -11,16 +12,14 @@ use super::{
pub struct SnapshotRbxlx; pub struct SnapshotRbxlx;
impl SnapshotMiddleware for SnapshotRbxlx { impl SnapshotMiddleware for SnapshotRbxlx {
fn from_vfs<F: VfsFetcher>( fn from_vfs(context: &InstanceContext, vfs: &Vfs, path: &Path) -> SnapshotInstanceResult {
context: &InstanceContext, let meta = vfs.metadata(path)?;
vfs: &Vfs<F>,
entry: &VfsEntry, if meta.is_dir() {
) -> SnapshotInstanceResult {
if entry.is_directory() {
return Ok(None); return Ok(None);
} }
let instance_name = match match_file_name(entry.path(), ".rbxlx") { let instance_name = match match_file_name(path, ".rbxlx") {
Some(name) => name, Some(name) => name,
None => return Ok(None), None => return Ok(None),
}; };
@@ -28,7 +27,7 @@ impl SnapshotMiddleware for SnapshotRbxlx {
let options = rbx_xml::DecodeOptions::new() let options = rbx_xml::DecodeOptions::new()
.property_behavior(rbx_xml::DecodePropertyBehavior::ReadUnknown); .property_behavior(rbx_xml::DecodePropertyBehavior::ReadUnknown);
let temp_tree = rbx_xml::from_reader(entry.contents(vfs)?.as_slice(), options) let temp_tree = rbx_xml::from_reader(vfs.read(path)?.as_slice(), options)
.expect("TODO: Handle rbx_xml errors"); .expect("TODO: Handle rbx_xml errors");
let root_id = temp_tree.get_root_id(); let root_id = temp_tree.get_root_id();
@@ -37,8 +36,8 @@ impl SnapshotMiddleware for SnapshotRbxlx {
.name(instance_name) .name(instance_name)
.metadata( .metadata(
InstanceMetadata::new() InstanceMetadata::new()
.instigating_source(entry.path()) .instigating_source(path)
.relevant_paths(vec![entry.path().to_path_buf()]) .relevant_paths(vec![path.to_path_buf()])
.context(context), .context(context),
); );

View File

@@ -1,11 +1,9 @@
use std::collections::HashMap; use std::{collections::HashMap, path::Path};
use memofs::Vfs;
use rbx_dom_weak::{RbxInstanceProperties, RbxTree}; use rbx_dom_weak::{RbxInstanceProperties, RbxTree};
use crate::{ use crate::snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot};
snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot},
vfs::{Vfs, VfsEntry, VfsFetcher},
};
use super::{ use super::{
middleware::{SnapshotInstanceResult, SnapshotMiddleware}, middleware::{SnapshotInstanceResult, SnapshotMiddleware},
@@ -15,16 +13,14 @@ use super::{
pub struct SnapshotRbxm; pub struct SnapshotRbxm;
impl SnapshotMiddleware for SnapshotRbxm { impl SnapshotMiddleware for SnapshotRbxm {
fn from_vfs<F: VfsFetcher>( fn from_vfs(context: &InstanceContext, vfs: &Vfs, path: &Path) -> SnapshotInstanceResult {
context: &InstanceContext, let meta = vfs.metadata(path)?;
vfs: &Vfs<F>,
entry: &VfsEntry, if meta.is_dir() {
) -> SnapshotInstanceResult {
if entry.is_directory() {
return Ok(None); return Ok(None);
} }
let instance_name = match match_file_name(entry.path(), ".rbxm") { let instance_name = match match_file_name(path, ".rbxm") {
Some(name) => name, Some(name) => name,
None => return Ok(None), None => return Ok(None),
}; };
@@ -36,7 +32,7 @@ impl SnapshotMiddleware for SnapshotRbxm {
}); });
let root_id = temp_tree.get_root_id(); let root_id = temp_tree.get_root_id();
rbx_binary::decode(&mut temp_tree, root_id, entry.contents(vfs)?.as_slice()) rbx_binary::decode(&mut temp_tree, root_id, vfs.read(path)?.as_slice())
.expect("TODO: Handle rbx_binary errors"); .expect("TODO: Handle rbx_binary errors");
let root_instance = temp_tree.get_instance(root_id).unwrap(); let root_instance = temp_tree.get_instance(root_id).unwrap();
@@ -47,8 +43,8 @@ impl SnapshotMiddleware for SnapshotRbxm {
.name(instance_name) .name(instance_name)
.metadata( .metadata(
InstanceMetadata::new() InstanceMetadata::new()
.instigating_source(entry.path()) .instigating_source(path)
.relevant_paths(vec![entry.path().to_path_buf()]) .relevant_paths(vec![path.to_path_buf()])
.context(context), .context(context),
); );
@@ -63,20 +59,26 @@ impl SnapshotMiddleware for SnapshotRbxm {
mod test { mod test {
use super::*; use super::*;
use crate::vfs::{NoopFetcher, VfsDebug, VfsSnapshot}; use memofs::{InMemoryFs, VfsSnapshot};
#[test] #[test]
fn model_from_vfs() { fn model_from_vfs() {
let mut vfs = Vfs::new(NoopFetcher); let mut imfs = InMemoryFs::new();
let file = VfsSnapshot::file(include_bytes!("../../assets/test-folder.rbxm").to_vec()); imfs.load_snapshot(
"/foo.rbxm",
VfsSnapshot::file(include_bytes!("../../assets/test-folder.rbxm").to_vec()),
)
.unwrap();
vfs.debug_load_snapshot("/foo.rbxm", file); let mut vfs = Vfs::new(imfs);
let entry = vfs.get("/foo.rbxm").unwrap(); let instance_snapshot = SnapshotRbxm::from_vfs(
let instance_snapshot = &InstanceContext::default(),
SnapshotRbxm::from_vfs(&InstanceContext::default(), &mut vfs, &entry) &mut vfs,
.unwrap() Path::new("/foo.rbxm"),
.unwrap(); )
.unwrap()
.unwrap();
assert_eq!(instance_snapshot.name, "foo"); assert_eq!(instance_snapshot.name, "foo");
assert_eq!(instance_snapshot.class_name, "Folder"); assert_eq!(instance_snapshot.class_name, "Folder");

View File

@@ -1,7 +1,8 @@
use crate::{ use std::path::Path;
snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot},
vfs::{Vfs, VfsEntry, VfsFetcher}, use memofs::Vfs;
};
use crate::snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot};
use super::{ use super::{
middleware::{SnapshotInstanceResult, SnapshotMiddleware}, middleware::{SnapshotInstanceResult, SnapshotMiddleware},
@@ -11,16 +12,14 @@ use super::{
pub struct SnapshotRbxmx; pub struct SnapshotRbxmx;
impl SnapshotMiddleware for SnapshotRbxmx { impl SnapshotMiddleware for SnapshotRbxmx {
fn from_vfs<F: VfsFetcher>( fn from_vfs(context: &InstanceContext, vfs: &Vfs, path: &Path) -> SnapshotInstanceResult {
context: &InstanceContext, let meta = vfs.metadata(path)?;
vfs: &Vfs<F>,
entry: &VfsEntry, if meta.is_dir() {
) -> SnapshotInstanceResult {
if entry.is_directory() {
return Ok(None); return Ok(None);
} }
let instance_name = match match_file_name(entry.path(), ".rbxmx") { let instance_name = match match_file_name(path, ".rbxmx") {
Some(name) => name, Some(name) => name,
None => return Ok(None), None => return Ok(None),
}; };
@@ -28,7 +27,7 @@ impl SnapshotMiddleware for SnapshotRbxmx {
let options = rbx_xml::DecodeOptions::new() let options = rbx_xml::DecodeOptions::new()
.property_behavior(rbx_xml::DecodePropertyBehavior::ReadUnknown); .property_behavior(rbx_xml::DecodePropertyBehavior::ReadUnknown);
let temp_tree = rbx_xml::from_reader(entry.contents(vfs)?.as_slice(), options) let temp_tree = rbx_xml::from_reader(vfs.read(path)?.as_slice(), options)
.expect("TODO: Handle rbx_xml errors"); .expect("TODO: Handle rbx_xml errors");
let root_instance = temp_tree.get_instance(temp_tree.get_root_id()).unwrap(); let root_instance = temp_tree.get_instance(temp_tree.get_root_id()).unwrap();
@@ -39,8 +38,8 @@ impl SnapshotMiddleware for SnapshotRbxmx {
.name(instance_name) .name(instance_name)
.metadata( .metadata(
InstanceMetadata::new() InstanceMetadata::new()
.instigating_source(entry.path()) .instigating_source(path)
.relevant_paths(vec![entry.path().to_path_buf()]) .relevant_paths(vec![path.to_path_buf()])
.context(context), .context(context),
); );
@@ -55,36 +54,40 @@ impl SnapshotMiddleware for SnapshotRbxmx {
mod test { mod test {
use super::*; use super::*;
use std::collections::HashMap; use memofs::{InMemoryFs, VfsSnapshot};
use crate::vfs::{NoopFetcher, VfsDebug, VfsSnapshot};
#[test] #[test]
fn model_from_vfs() { fn plain_folder() {
let mut vfs = Vfs::new(NoopFetcher); let mut imfs = InMemoryFs::new();
let file = VfsSnapshot::file( imfs.load_snapshot(
r#" "/foo.rbxmx",
<roblox version="4"> VfsSnapshot::file(
<Item class="Folder" referent="0"> r#"
<Properties> <roblox version="4">
<string name="Name">THIS NAME IS IGNORED</string> <Item class="Folder" referent="0">
</Properties> <Properties>
</Item> <string name="Name">THIS NAME IS IGNORED</string>
</roblox> </Properties>
"#, </Item>
); </roblox>
"#,
),
)
.unwrap();
vfs.debug_load_snapshot("/foo.rbxmx", file); let mut vfs = Vfs::new(imfs);
let entry = vfs.get("/foo.rbxmx").unwrap(); let instance_snapshot = SnapshotRbxmx::from_vfs(
let instance_snapshot = &InstanceContext::default(),
SnapshotRbxmx::from_vfs(&InstanceContext::default(), &mut vfs, &entry) &mut vfs,
.unwrap() Path::new("/foo.rbxmx"),
.unwrap(); )
.unwrap()
.unwrap();
assert_eq!(instance_snapshot.name, "foo"); assert_eq!(instance_snapshot.name, "foo");
assert_eq!(instance_snapshot.class_name, "Folder"); assert_eq!(instance_snapshot.class_name, "Folder");
assert_eq!(instance_snapshot.properties, HashMap::new()); assert_eq!(instance_snapshot.properties, Default::default());
assert_eq!(instance_snapshot.children, Vec::new()); assert_eq!(instance_snapshot.children, Vec::new());
} }
} }

View File

@@ -1,40 +1,36 @@
use std::str; use std::{path::Path, str};
use maplit::hashmap; use maplit::hashmap;
use rbx_dom_weak::{RbxId, RbxTree, RbxValue}; use memofs::{IoResultExt, Vfs};
use rbx_dom_weak::RbxValue;
use crate::{ use crate::snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot};
snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot},
vfs::{FileSnapshot, FsResultExt, Vfs, VfsEntry, VfsFetcher, VfsSnapshot},
};
use super::{ use super::{
error::SnapshotError, error::SnapshotError,
meta_file::AdjacentMetadata, meta_file::AdjacentMetadata,
middleware::{SnapshotFileResult, SnapshotInstanceResult, SnapshotMiddleware}, middleware::{SnapshotInstanceResult, SnapshotMiddleware},
util::match_file_name, util::match_file_name,
}; };
pub struct SnapshotTxt; pub struct SnapshotTxt;
impl SnapshotMiddleware for SnapshotTxt { impl SnapshotMiddleware for SnapshotTxt {
fn from_vfs<F: VfsFetcher>( fn from_vfs(context: &InstanceContext, vfs: &Vfs, path: &Path) -> SnapshotInstanceResult {
context: &InstanceContext, let meta = vfs.metadata(path)?;
vfs: &Vfs<F>,
entry: &VfsEntry, if meta.is_dir() {
) -> SnapshotInstanceResult {
if entry.is_directory() {
return Ok(None); return Ok(None);
} }
let instance_name = match match_file_name(entry.path(), ".txt") { let instance_name = match match_file_name(path, ".txt") {
Some(name) => name, Some(name) => name,
None => return Ok(None), None => return Ok(None),
}; };
let contents = entry.contents(vfs)?; let contents = vfs.read(path)?;
let contents_str = str::from_utf8(&contents) let contents_str = str::from_utf8(&contents)
.map_err(|err| SnapshotError::file_contents_bad_unicode(err, entry.path()))? .map_err(|err| SnapshotError::file_contents_bad_unicode(err, path))?
.to_string(); .to_string();
let properties = hashmap! { let properties = hashmap! {
@@ -43,9 +39,7 @@ impl SnapshotMiddleware for SnapshotTxt {
}, },
}; };
let meta_path = entry let meta_path = path.with_file_name(format!("{}.meta.json", instance_name));
.path()
.with_file_name(format!("{}.meta.json", instance_name));
let mut snapshot = InstanceSnapshot::new() let mut snapshot = InstanceSnapshot::new()
.name(instance_name) .name(instance_name)
@@ -53,99 +47,39 @@ impl SnapshotMiddleware for SnapshotTxt {
.properties(properties) .properties(properties)
.metadata( .metadata(
InstanceMetadata::new() InstanceMetadata::new()
.instigating_source(entry.path()) .instigating_source(path)
.relevant_paths(vec![entry.path().to_path_buf(), meta_path.clone()]) .relevant_paths(vec![path.to_path_buf(), meta_path.clone()])
.context(context), .context(context),
); );
if let Some(meta_entry) = vfs.get(meta_path).with_not_found()? { if let Some(meta_contents) = vfs.read(&meta_path).with_not_found()? {
let meta_contents = meta_entry.contents(vfs)?; let mut metadata = AdjacentMetadata::from_slice(&meta_contents, &meta_path)?;
let mut metadata = AdjacentMetadata::from_slice(&meta_contents);
metadata.apply_all(&mut snapshot); metadata.apply_all(&mut snapshot);
} }
Ok(Some(snapshot)) Ok(Some(snapshot))
} }
fn from_instance(tree: &RbxTree, id: RbxId) -> SnapshotFileResult {
let instance = tree.get_instance(id).unwrap();
if instance.class_name != "StringValue" {
return None;
}
if !instance.get_children_ids().is_empty() {
return None;
}
let value = match instance.properties.get("Value") {
Some(RbxValue::String { value }) => value.clone(),
Some(_) => panic!("wrong type ahh"),
None => String::new(),
};
let snapshot = VfsSnapshot::File(FileSnapshot {
contents: value.into_bytes(),
});
let mut file_name = instance.name.clone();
file_name.push_str(".txt");
Some((file_name, snapshot))
}
} }
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use super::*; use super::*;
use insta::assert_yaml_snapshot; use memofs::{InMemoryFs, VfsSnapshot};
use maplit::hashmap;
use rbx_dom_weak::RbxInstanceProperties;
use crate::vfs::{NoopFetcher, VfsDebug};
#[test] #[test]
fn instance_from_vfs() { fn instance_from_vfs() {
let mut vfs = Vfs::new(NoopFetcher); let mut imfs = InMemoryFs::new();
let file = VfsSnapshot::file("Hello there!"); imfs.load_snapshot("/foo.txt", VfsSnapshot::file("Hello there!"))
.unwrap();
vfs.debug_load_snapshot("/foo.txt", file); let mut vfs = Vfs::new(imfs.clone());
let entry = vfs.get("/foo.txt").unwrap();
let instance_snapshot = let instance_snapshot =
SnapshotTxt::from_vfs(&InstanceContext::default(), &mut vfs, &entry) SnapshotTxt::from_vfs(&InstanceContext::default(), &mut vfs, Path::new("/foo.txt"))
.unwrap() .unwrap()
.unwrap(); .unwrap();
assert_yaml_snapshot!(instance_snapshot); insta::assert_yaml_snapshot!(instance_snapshot);
}
#[test]
fn vfs_from_instance() {
let tree = RbxTree::new(string_value("Root", "Hello, world!"));
let root_id = tree.get_root_id();
let (_file_name, _file) = SnapshotTxt::from_instance(&tree, root_id).unwrap();
}
fn folder(name: impl Into<String>) -> RbxInstanceProperties {
RbxInstanceProperties {
name: name.into(),
class_name: "Folder".to_owned(),
properties: Default::default(),
}
}
fn string_value(name: impl Into<String>, value: impl Into<String>) -> RbxInstanceProperties {
RbxInstanceProperties {
name: name.into(),
class_name: "StringValue".to_owned(),
properties: hashmap! {
"Value".to_owned() => RbxValue::String {
value: value.into(),
},
},
}
} }
} }

View File

@@ -1,55 +0,0 @@
use std::{error::Error, fmt, io, path::PathBuf};
pub type FsResult<T> = Result<T, FsError>;
pub use io::ErrorKind as FsErrorKind;
pub trait FsResultExt<T> {
fn with_not_found(self) -> Result<Option<T>, FsError>;
}
impl<T> FsResultExt<T> for Result<T, FsError> {
fn with_not_found(self) -> Result<Option<T>, FsError> {
match self {
Ok(value) => Ok(Some(value)),
Err(ref err) if err.kind() == FsErrorKind::NotFound => Ok(None),
Err(err) => Err(err),
}
}
}
/// A wrapper around io::Error that also attaches the path associated with the
/// error.
#[derive(Debug)]
pub struct FsError {
source: io::Error,
path: PathBuf,
}
impl FsError {
pub fn new<P: Into<PathBuf>>(source: io::Error, path: P) -> FsError {
FsError {
source,
path: path.into(),
}
}
pub fn kind(&self) -> FsErrorKind {
self.source.kind()
}
pub fn into_raw(self) -> (io::Error, PathBuf) {
(self.source, self.path)
}
}
impl Error for FsError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&self.source)
}
}
impl fmt::Display for FsError {
fn fmt(&self, output: &mut fmt::Formatter) -> fmt::Result {
write!(output, "{}: {}", self.path.display(), self.source)
}
}

View File

@@ -1,8 +0,0 @@
use std::path::PathBuf;
#[derive(Debug)]
pub enum VfsEvent {
Modified(PathBuf),
Created(PathBuf),
Removed(PathBuf),
}

View File

@@ -1,37 +0,0 @@
use std::{
io,
path::{Path, PathBuf},
};
use crossbeam_channel::Receiver;
use super::event::VfsEvent;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileType {
File,
Directory,
}
/// The generic interface that `Vfs` uses to lazily read files from the disk.
/// In tests, it's stubbed out to do different versions of absolutely nothing
/// depending on the test.
pub trait VfsFetcher {
fn file_type(&self, path: &Path) -> io::Result<FileType>;
fn read_children(&self, path: &Path) -> io::Result<Vec<PathBuf>>;
fn read_contents(&self, path: &Path) -> io::Result<Vec<u8>>;
fn create_directory(&self, path: &Path) -> io::Result<()>;
fn write_file(&self, path: &Path, contents: &[u8]) -> io::Result<()>;
fn remove(&self, path: &Path) -> io::Result<()>;
fn receiver(&self) -> Receiver<VfsEvent>;
fn watch(&self, _path: &Path) {}
fn unwatch(&self, _path: &Path) {}
/// A method intended for debugging what paths the fetcher is watching.
fn watched_paths(&self) -> Vec<PathBuf> {
Vec::new()
}
}

View File

@@ -1,24 +0,0 @@
mod error;
mod event;
mod fetcher;
mod noop_fetcher;
mod real_fetcher;
mod snapshot;
// I don't think module inception is a real problem?
#[allow(clippy::module_inception)]
mod vfs;
pub use error::*;
pub use event::*;
pub use fetcher::*;
pub use noop_fetcher::*;
pub use real_fetcher::*;
pub use snapshot::*;
pub use vfs::*;
#[cfg(test)]
mod test_fetcher;
#[cfg(test)]
pub use test_fetcher::*;

View File

@@ -1,62 +0,0 @@
//! Implements the VFS fetcher interface for a fake filesystem using Rust's
//! std::fs interface.
// This interface is only used for testing, so it's okay if it isn't used.
#![allow(unused)]
use std::{
io,
path::{Path, PathBuf},
};
use crossbeam_channel::Receiver;
use super::{
event::VfsEvent,
fetcher::{FileType, VfsFetcher},
};
pub struct NoopFetcher;
impl VfsFetcher for NoopFetcher {
fn file_type(&self, _path: &Path) -> io::Result<FileType> {
Err(io::Error::new(
io::ErrorKind::NotFound,
"NoopFetcher always returns NotFound",
))
}
fn read_children(&self, _path: &Path) -> io::Result<Vec<PathBuf>> {
Err(io::Error::new(
io::ErrorKind::NotFound,
"NoopFetcher always returns NotFound",
))
}
fn read_contents(&self, _path: &Path) -> io::Result<Vec<u8>> {
Err(io::Error::new(
io::ErrorKind::NotFound,
"NoopFetcher always returns NotFound",
))
}
fn create_directory(&self, _path: &Path) -> io::Result<()> {
Ok(())
}
fn write_file(&self, _path: &Path, _contents: &[u8]) -> io::Result<()> {
Ok(())
}
fn remove(&self, _path: &Path) -> io::Result<()> {
Ok(())
}
fn watch(&self, _path: &Path) {}
fn unwatch(&self, _path: &Path) {}
fn receiver(&self) -> Receiver<VfsEvent> {
crossbeam_channel::never()
}
}

View File

@@ -1,225 +0,0 @@
//! Implements the VFS fetcher interface for the real filesystem using Rust's
//! std::fs interface and notify as the file watcher.
use std::{
collections::HashSet,
fs, io,
path::{Path, PathBuf},
sync::{mpsc, Mutex},
time::Duration,
};
use crossbeam_channel::{unbounded, Receiver, Sender};
use jod_thread::JoinHandle;
use notify::{DebouncedEvent, RecommendedWatcher, RecursiveMode, Watcher};
use super::{
event::VfsEvent,
fetcher::{FileType, VfsFetcher},
};
/// Workaround to disable the file watcher for processes that don't need it,
/// since notify appears hang on to mpsc Sender objects too long, causing Rojo
/// to deadlock on drop.
///
/// We can make constructing the watcher optional in order to hotfix rojo build.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WatchMode {
Enabled,
Disabled,
}
pub struct RealFetcher {
// Drop order is relevant here!
//
// `watcher` must be dropped before `_converter_thread` or else joining the
// thread will cause a deadlock.
watcher: Option<Mutex<RecommendedWatcher>>,
/// Thread handle to convert notify's mpsc channel messages into
/// crossbeam_channel messages.
_converter_thread: JoinHandle<()>,
/// The crossbeam receiver filled with events from the converter thread.
receiver: Receiver<VfsEvent>,
/// All of the paths that the fetcher is watching, tracked here because
/// notify does not expose this information.
watched_paths: Mutex<HashSet<PathBuf>>,
}
impl RealFetcher {
pub fn new(watch_mode: WatchMode) -> RealFetcher {
log::trace!("Starting RealFetcher with watch mode {:?}", watch_mode);
let (notify_sender, notify_receiver) = mpsc::channel();
let (sender, receiver) = unbounded();
let handle = jod_thread::Builder::new()
.name("notify message converter".to_owned())
.spawn(move || {
log::trace!("RealFetcher converter thread started");
converter_thread(notify_receiver, sender);
log::trace!("RealFetcher converter thread stopped");
})
.expect("Could not start message converter thread");
// TODO: Investigate why notify hangs onto notify_sender too long,
// causing our program to deadlock. Once this is fixed, watcher no
// longer needs to be optional, but is still maybe useful?
let watcher = match watch_mode {
WatchMode::Enabled => {
let watcher = notify::watcher(notify_sender, Duration::from_millis(300))
.expect("Couldn't start 'notify' file watcher");
Some(Mutex::new(watcher))
}
WatchMode::Disabled => None,
};
RealFetcher {
watcher,
_converter_thread: handle,
receiver,
watched_paths: Mutex::new(HashSet::new()),
}
}
}
fn converter_thread(notify_receiver: mpsc::Receiver<DebouncedEvent>, sender: Sender<VfsEvent>) {
use DebouncedEvent::*;
for event in notify_receiver {
log::trace!("Notify event: {:?}", event);
match event {
Create(path) => sender.send(VfsEvent::Created(path)).unwrap(),
Write(path) => sender.send(VfsEvent::Modified(path)).unwrap(),
Remove(path) => sender.send(VfsEvent::Removed(path)).unwrap(),
Rename(from_path, to_path) => {
sender.send(VfsEvent::Created(from_path)).unwrap();
sender.send(VfsEvent::Removed(to_path)).unwrap();
}
Rescan => {
log::warn!("Unhandled filesystem rescan event.");
log::warn!(
"Please file an issue! Rojo may need to handle this case, but does not yet."
);
}
Error(err, maybe_path) => {
log::warn!("Unhandled filesystem error: {}", err);
match maybe_path {
Some(path) => log::warn!("On path {}", path.display()),
None => log::warn!("No path was associated with this error."),
}
log::warn!(
"Rojo may need to handle this. If this happens again, please file an issue!"
);
}
NoticeWrite(_) | NoticeRemove(_) | Chmod(_) => {}
}
}
}
impl VfsFetcher for RealFetcher {
fn file_type(&self, path: &Path) -> io::Result<FileType> {
let metadata = fs::metadata(path)?;
if metadata.is_file() {
Ok(FileType::File)
} else {
Ok(FileType::Directory)
}
}
fn read_children(&self, path: &Path) -> io::Result<Vec<PathBuf>> {
log::trace!("Reading directory {}", path.display());
let mut result = Vec::new();
let iter = fs::read_dir(path)?;
for entry in iter {
result.push(entry?.path());
}
Ok(result)
}
fn read_contents(&self, path: &Path) -> io::Result<Vec<u8>> {
log::trace!("Reading file {}", path.display());
fs::read(path)
}
fn create_directory(&self, path: &Path) -> io::Result<()> {
log::trace!("Creating directory {}", path.display());
fs::create_dir(path)
}
fn write_file(&self, path: &Path, contents: &[u8]) -> io::Result<()> {
log::trace!("Writing path {}", path.display());
fs::write(path, contents)
}
fn remove(&self, path: &Path) -> io::Result<()> {
log::trace!("Removing path {}", path.display());
let metadata = fs::metadata(path)?;
if metadata.is_file() {
fs::remove_file(path)
} else {
fs::remove_dir_all(path)
}
}
fn watch(&self, path: &Path) {
log::trace!("Watching path {}", path.display());
if let Some(watcher_handle) = &self.watcher {
let mut watcher = watcher_handle.lock().unwrap();
match watcher.watch(path, RecursiveMode::NonRecursive) {
Ok(_) => {
let mut watched_paths = self.watched_paths.lock().unwrap();
watched_paths.insert(path.to_path_buf());
}
Err(err) => {
log::warn!("Couldn't watch path {}: {:?}", path.display(), err);
}
}
}
}
fn unwatch(&self, path: &Path) {
log::trace!("Stopped watching path {}", path.display());
if let Some(watcher_handle) = &self.watcher {
let mut watcher = watcher_handle.lock().unwrap();
// Remove the path from our watched paths regardless of the outcome
// of notify's unwatch to ensure we drop old paths in the event of a
// rename.
let mut watched_paths = self.watched_paths.lock().unwrap();
watched_paths.remove(path);
if let Err(err) = watcher.unwatch(path) {
log::warn!("Couldn't unwatch path {}: {:?}", path.display(), err);
}
}
}
fn receiver(&self) -> Receiver<VfsEvent> {
self.receiver.clone()
}
fn watched_paths(&self) -> Vec<PathBuf> {
let watched_paths = self.watched_paths.lock().unwrap();
watched_paths.iter().cloned().collect()
}
}

View File

@@ -1,42 +0,0 @@
// This file is non-critical and used for testing, so it's okay if it's unused.
#![allow(unused)]
use std::collections::HashMap;
#[derive(Debug, Clone)]
pub enum VfsSnapshot {
File(FileSnapshot),
Directory(DirectorySnapshot),
}
impl VfsSnapshot {
/// Create a new file VfsSnapshot with the given contents.
pub fn file(contents: impl Into<Vec<u8>>) -> VfsSnapshot {
VfsSnapshot::File(FileSnapshot {
contents: contents.into(),
})
}
/// Create a new directory VfsSnapshot with the given children.
pub fn dir<S: Into<String>>(children: HashMap<S, VfsSnapshot>) -> VfsSnapshot {
let children = children.into_iter().map(|(k, v)| (k.into(), v)).collect();
VfsSnapshot::Directory(DirectorySnapshot { children })
}
pub fn empty_dir() -> VfsSnapshot {
VfsSnapshot::Directory(DirectorySnapshot {
children: Default::default(),
})
}
}
#[derive(Debug, Clone)]
pub struct FileSnapshot {
pub contents: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct DirectorySnapshot {
pub children: HashMap<String, VfsSnapshot>,
}

View File

@@ -1,171 +0,0 @@
//! Implements the VFS fetcher interface for a fake filesystem that can be
//! mutated and have changes signaled through it.
//!
//! This is useful for testing how things using Vfs react to changed events
//! without relying on the real filesystem implementation, which is very
//! platform-specific.
// This interface is only used for testing, so it's okay if it isn't used.
#![allow(unused)]
use std::{
io,
path::{self, Path, PathBuf},
sync::{Arc, Mutex},
};
use crossbeam_channel::{unbounded, Receiver, Sender};
use crate::path_map::PathMap;
use super::{
event::VfsEvent,
fetcher::{FileType, VfsFetcher},
snapshot::VfsSnapshot,
};
#[derive(Clone)]
pub struct TestFetcherState {
inner: Arc<Mutex<TestFetcherStateInner>>,
}
impl TestFetcherState {
pub fn load_snapshot<P: AsRef<Path>>(&self, path: P, snapshot: VfsSnapshot) {
let mut inner = self.inner.lock().unwrap();
inner.load_snapshot(path.as_ref().to_path_buf(), snapshot);
}
pub fn remove<P: AsRef<Path>>(&self, path: P) {
let mut inner = self.inner.lock().unwrap();
inner.remove(path.as_ref());
}
pub fn raise_event(&self, event: VfsEvent) {
let mut inner = self.inner.lock().unwrap();
inner.raise_event(event);
}
}
pub enum TestFetcherEntry {
File(Vec<u8>),
Dir,
}
struct TestFetcherStateInner {
entries: PathMap<TestFetcherEntry>,
sender: Sender<VfsEvent>,
}
impl TestFetcherStateInner {
fn new(sender: Sender<VfsEvent>) -> Self {
let mut entries = PathMap::new();
entries.insert(Path::new("/"), TestFetcherEntry::Dir);
Self { sender, entries }
}
fn load_snapshot(&mut self, path: PathBuf, snapshot: VfsSnapshot) {
match snapshot {
VfsSnapshot::File(file) => {
self.entries
.insert(path, TestFetcherEntry::File(file.contents));
}
VfsSnapshot::Directory(directory) => {
self.entries.insert(path.clone(), TestFetcherEntry::Dir);
for (child_name, child) in directory.children.into_iter() {
self.load_snapshot(path.join(child_name), child);
}
}
}
}
fn remove(&mut self, path: &Path) {
self.entries.remove(path);
}
fn raise_event(&mut self, event: VfsEvent) {
self.sender.send(event).unwrap();
}
}
pub struct TestFetcher {
state: TestFetcherState,
receiver: Receiver<VfsEvent>,
}
impl TestFetcher {
pub fn new() -> (TestFetcherState, Self) {
let (sender, receiver) = unbounded();
let state = TestFetcherState {
inner: Arc::new(Mutex::new(TestFetcherStateInner::new(sender))),
};
(state.clone(), Self { receiver, state })
}
}
impl VfsFetcher for TestFetcher {
fn file_type(&self, path: &Path) -> io::Result<FileType> {
let inner = self.state.inner.lock().unwrap();
match inner.entries.get(path) {
Some(TestFetcherEntry::File(_)) => Ok(FileType::File),
Some(TestFetcherEntry::Dir) => Ok(FileType::Directory),
None => Err(io::Error::new(io::ErrorKind::NotFound, "Path not found")),
}
}
fn read_children(&self, path: &Path) -> io::Result<Vec<PathBuf>> {
let inner = self.state.inner.lock().unwrap();
Ok(inner
.entries
.children(path)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Path not found"))?
.into_iter()
.map(|path| path.to_path_buf())
.collect())
}
fn read_contents(&self, path: &Path) -> io::Result<Vec<u8>> {
let inner = self.state.inner.lock().unwrap();
let node = inner.entries.get(path);
match node {
Some(TestFetcherEntry::File(contents)) => Ok(contents.clone()),
Some(TestFetcherEntry::Dir) => Err(io::Error::new(
io::ErrorKind::Other,
"Cannot read contents of a directory",
)),
None => Err(io::Error::new(io::ErrorKind::NotFound, "Path not found")),
}
}
fn create_directory(&self, _path: &Path) -> io::Result<()> {
Err(io::Error::new(
io::ErrorKind::Other,
"TestFetcher is not mutable yet",
))
}
fn write_file(&self, _path: &Path, _contents: &[u8]) -> io::Result<()> {
Err(io::Error::new(
io::ErrorKind::Other,
"TestFetcher is not mutable yet",
))
}
fn remove(&self, _path: &Path) -> io::Result<()> {
Err(io::Error::new(
io::ErrorKind::Other,
"TestFetcher is not mutable yet",
))
}
fn receiver(&self) -> Receiver<VfsEvent> {
self.receiver.clone()
}
}

View File

@@ -1,614 +0,0 @@
use std::{
io,
path::{Path, PathBuf},
sync::{Arc, Mutex},
};
use crossbeam_channel::Receiver;
use crate::path_map::PathMap;
use super::{
error::{FsError, FsResult},
event::VfsEvent,
fetcher::{FileType, VfsFetcher},
snapshot::VfsSnapshot,
};
/// An in-memory filesystem that can be incrementally populated and updated as
/// filesystem modification events occur.
///
/// All operations on the `Vfs` are lazy and do I/O as late as they can to
/// avoid reading extraneous files or directories from the disk. This means that
/// they all take `self` mutably, and means that it isn't possible to hold
/// references to the internal state of the Vfs while traversing it!
///
/// Most operations return `VfsEntry` objects to work around this, which is
/// effectively a index into the `Vfs`.
pub struct Vfs<F> {
/// A hierarchical map from paths to items that have been read or partially
/// read into memory by the Vfs.
data: Mutex<PathMap<VfsItem>>,
/// This Vfs's fetcher, which is used for all actual interactions with the
/// filesystem. It's referred to by the type parameter `F` all over, and is
/// generic in order to make it feasible to mock.
fetcher: F,
}
impl<F: VfsFetcher> Vfs<F> {
pub fn new(fetcher: F) -> Self {
Self {
data: Mutex::new(PathMap::new()),
fetcher,
}
}
pub fn change_receiver(&self) -> Receiver<VfsEvent> {
self.fetcher.receiver()
}
pub fn commit_change(&self, event: &VfsEvent) -> FsResult<()> {
use VfsEvent::*;
log::trace!("Committing Vfs change {:?}", event);
let mut data = self.data.lock().unwrap();
match event {
Created(path) | Modified(path) => {
Self::raise_file_changed(&mut data, &self.fetcher, path)?;
}
Removed(path) => {
Self::raise_file_removed(&mut data, &self.fetcher, path)?;
}
}
Ok(())
}
pub fn get(&self, path: impl AsRef<Path>) -> FsResult<VfsEntry> {
let mut data = self.data.lock().unwrap();
Self::get_internal(&mut data, &self.fetcher, path)
}
pub fn get_contents(&self, path: impl AsRef<Path>) -> FsResult<Arc<Vec<u8>>> {
let path = path.as_ref();
let mut data = self.data.lock().unwrap();
Self::read_if_not_exists(&mut data, &self.fetcher, path)?;
match data.get_mut(path).unwrap() {
VfsItem::File(file) => {
if file.contents.is_none() {
file.contents = Some(
self.fetcher
.read_contents(path)
.map(Arc::new)
.map_err(|err| FsError::new(err, path.to_path_buf()))?,
);
}
Ok(file.contents.clone().unwrap())
}
VfsItem::Directory(_) => Err(FsError::new(
io::Error::new(io::ErrorKind::Other, "Can't read a directory"),
path.to_path_buf(),
)),
}
}
pub fn get_children(&self, path: impl AsRef<Path>) -> FsResult<Vec<VfsEntry>> {
let path = path.as_ref();
let mut data = self.data.lock().unwrap();
Self::read_if_not_exists(&mut data, &self.fetcher, path)?;
match data.get_mut(path).unwrap() {
VfsItem::Directory(dir) => {
self.fetcher.watch(path);
// If the directory hasn't been marked as enumerated yet, find
// all of its children and insert them into the VFS.
if !dir.children_enumerated {
dir.children_enumerated = true;
let children = self
.fetcher
.read_children(path)
.map_err(|err| FsError::new(err, path.to_path_buf()))?;
for path in children {
Self::get_internal(&mut data, &self.fetcher, path)?;
}
}
data.children(path)
.unwrap() // TODO: Handle None here, which means the PathMap entry did not exist.
.into_iter()
.map(PathBuf::from) // Convert paths from &Path to PathBuf
.collect::<Vec<PathBuf>>() // Collect all PathBufs, since self.get needs to borrow self mutably.
.into_iter()
.map(|path| Self::get_internal(&mut data, &self.fetcher, path))
.collect::<FsResult<Vec<VfsEntry>>>()
}
VfsItem::File(_) => Err(FsError::new(
io::Error::new(io::ErrorKind::Other, "Can't read a directory"),
path.to_path_buf(),
)),
}
}
fn get_internal(
data: &mut PathMap<VfsItem>,
fetcher: &F,
path: impl AsRef<Path>,
) -> FsResult<VfsEntry> {
let path = path.as_ref();
Self::read_if_not_exists(data, fetcher, path)?;
let item = data.get(path).unwrap();
let is_file = match item {
VfsItem::File(_) => true,
VfsItem::Directory(_) => false,
};
Ok(VfsEntry {
path: item.path().to_path_buf(),
is_file,
})
}
fn raise_file_changed(
data: &mut PathMap<VfsItem>,
fetcher: &F,
path: impl AsRef<Path>,
) -> FsResult<()> {
let path = path.as_ref();
if !Self::would_be_resident(&data, path) {
log::trace!(
"Path would not be resident, skipping change: {}",
path.display()
);
return Ok(());
}
let new_type = fetcher
.file_type(path)
.map_err(|err| FsError::new(err, path.to_path_buf()))?;
match data.get_mut(path) {
Some(existing_item) => {
match (existing_item, &new_type) {
(VfsItem::File(existing_file), FileType::File) => {
// Invalidate the existing file contents.
// We can probably be smarter about this by reading the changed file.
existing_file.contents = None;
}
(VfsItem::Directory(_), FileType::Directory) => {
// No changes required, a directory updating doesn't mean anything to us.
fetcher.watch(path);
}
(VfsItem::File(_), FileType::Directory) => {
data.remove(path);
data.insert(
path.to_path_buf(),
VfsItem::new_from_type(FileType::Directory, path),
);
fetcher.watch(path);
}
(VfsItem::Directory(_), FileType::File) => {
data.remove(path);
data.insert(
path.to_path_buf(),
VfsItem::new_from_type(FileType::File, path),
);
fetcher.unwatch(path);
}
}
}
None => {
log::trace!("Inserting new path {}", path.display());
data.insert(path.to_path_buf(), VfsItem::new_from_type(new_type, path));
}
}
Ok(())
}
fn raise_file_removed(
data: &mut PathMap<VfsItem>,
fetcher: &F,
path: impl AsRef<Path>,
) -> FsResult<()> {
let path = path.as_ref();
if !Self::would_be_resident(data, path) {
return Ok(());
}
data.remove(path);
fetcher.unwatch(path);
Ok(())
}
/// Attempts to read the path into the `Vfs` if it doesn't exist.
///
/// This does not necessitate that file contents or directory children will
/// be read. Depending on the `VfsFetcher` implementation that the `Vfs`
/// is using, this call may read exactly only the given path and no more.
fn read_if_not_exists(data: &mut PathMap<VfsItem>, fetcher: &F, path: &Path) -> FsResult<()> {
if !data.contains_key(path) {
let kind = fetcher
.file_type(path)
.map_err(|err| FsError::new(err, path.to_path_buf()))?;
if kind == FileType::Directory {
fetcher.watch(path);
}
data.insert(path.to_path_buf(), VfsItem::new_from_type(kind, path));
}
Ok(())
}
/// Tells whether the given path, if it were loaded, would be loaded if it
/// existed.
///
/// Returns true if the path is loaded or if its parent is loaded, is a
/// directory, and is marked as having been enumerated before.
///
/// This idea corresponds to whether a file change event should result in
/// tangible changes to the in-memory filesystem. If a path would be
/// resident, we need to read it, and if its contents were known before, we
/// need to update them.
fn would_be_resident(data: &PathMap<VfsItem>, path: &Path) -> bool {
if data.contains_key(path) {
return true;
}
if let Some(parent) = path.parent() {
if let Some(VfsItem::Directory(dir)) = data.get(parent) {
return dir.children_enumerated;
}
}
false
}
}
/// Contains extra methods that should only be used for debugging. They're
/// broken out into a separate trait to make it more explicit to depend on them.
pub trait VfsDebug {
fn debug_load_snapshot<P: AsRef<Path>>(&self, path: P, snapshot: VfsSnapshot);
fn debug_is_file(&self, path: &Path) -> bool;
fn debug_contents(&self, path: &Path) -> Option<Arc<Vec<u8>>>;
fn debug_children(&self, path: &Path) -> Option<(bool, Vec<PathBuf>)>;
fn debug_orphans(&self) -> Vec<PathBuf>;
fn debug_watched_paths(&self) -> Vec<PathBuf>;
}
impl<F: VfsFetcher> VfsDebug for Vfs<F> {
fn debug_load_snapshot<P: AsRef<Path>>(&self, path: P, snapshot: VfsSnapshot) {
fn load_snapshot<P: AsRef<Path>>(
data: &mut PathMap<VfsItem>,
path: P,
snapshot: VfsSnapshot,
) {
let path = path.as_ref();
match snapshot {
VfsSnapshot::File(file) => {
data.insert(
path.to_path_buf(),
VfsItem::File(VfsFile {
path: path.to_path_buf(),
contents: Some(Arc::new(file.contents)),
}),
);
}
VfsSnapshot::Directory(directory) => {
data.insert(
path.to_path_buf(),
VfsItem::Directory(VfsDirectory {
path: path.to_path_buf(),
children_enumerated: true,
}),
);
for (child_name, child) in directory.children.into_iter() {
load_snapshot(data, path.join(child_name), child);
}
}
}
}
let mut data = self.data.lock().unwrap();
load_snapshot(&mut data, path, snapshot)
}
fn debug_is_file(&self, path: &Path) -> bool {
let data = self.data.lock().unwrap();
match data.get(path) {
Some(VfsItem::File(_)) => true,
_ => false,
}
}
fn debug_contents(&self, path: &Path) -> Option<Arc<Vec<u8>>> {
let data = self.data.lock().unwrap();
match data.get(path) {
Some(VfsItem::File(file)) => file.contents.clone(),
_ => None,
}
}
fn debug_children(&self, path: &Path) -> Option<(bool, Vec<PathBuf>)> {
let data = self.data.lock().unwrap();
match data.get(path) {
Some(VfsItem::Directory(dir)) => Some((
dir.children_enumerated,
data.children(path)
.unwrap()
.iter()
.map(|path| path.to_path_buf())
.collect(),
)),
_ => None,
}
}
fn debug_orphans(&self) -> Vec<PathBuf> {
let data = self.data.lock().unwrap();
data.orphans().map(|path| path.to_path_buf()).collect()
}
fn debug_watched_paths(&self) -> Vec<PathBuf> {
self.fetcher.watched_paths()
}
}
/// A reference to file or folder in an `Vfs`. Can only be produced by the
/// entry existing in the Vfs, but can later point to nothing if something
/// would invalidate that path.
///
/// This struct does not borrow from the Vfs since every operation has the
/// possibility to mutate the underlying data structure and move memory around.
pub struct VfsEntry {
path: PathBuf,
is_file: bool,
}
impl VfsEntry {
pub fn path(&self) -> &Path {
&self.path
}
pub fn contents(&self, vfs: &Vfs<impl VfsFetcher>) -> FsResult<Arc<Vec<u8>>> {
vfs.get_contents(&self.path)
}
pub fn children(&self, vfs: &Vfs<impl VfsFetcher>) -> FsResult<Vec<VfsEntry>> {
vfs.get_children(&self.path)
}
pub fn is_file(&self) -> bool {
self.is_file
}
pub fn is_directory(&self) -> bool {
!self.is_file
}
}
/// Internal structure describing potentially partially-resident files and
/// folders in the `Vfs`.
#[derive(Debug)]
pub enum VfsItem {
File(VfsFile),
Directory(VfsDirectory),
}
impl VfsItem {
fn path(&self) -> &Path {
match self {
VfsItem::File(file) => &file.path,
VfsItem::Directory(dir) => &dir.path,
}
}
fn new_from_type(kind: FileType, path: impl Into<PathBuf>) -> VfsItem {
match kind {
FileType::Directory => VfsItem::Directory(VfsDirectory {
path: path.into(),
children_enumerated: false,
}),
FileType::File => VfsItem::File(VfsFile {
path: path.into(),
contents: None,
}),
}
}
}
#[derive(Debug)]
pub struct VfsFile {
pub(super) path: PathBuf,
pub(super) contents: Option<Arc<Vec<u8>>>,
}
#[derive(Debug)]
pub struct VfsDirectory {
pub(super) path: PathBuf,
pub(super) children_enumerated: bool,
}
#[cfg(test)]
mod test {
use super::*;
use std::{cell::RefCell, rc::Rc};
use crossbeam_channel::Receiver;
use maplit::hashmap;
use super::super::{error::FsErrorKind, event::VfsEvent, noop_fetcher::NoopFetcher};
#[test]
fn from_snapshot_file() {
let vfs = Vfs::new(NoopFetcher);
let file = VfsSnapshot::file("hello, world!");
vfs.debug_load_snapshot("/hello.txt", file);
let contents = vfs.get_contents("/hello.txt").unwrap();
assert_eq!(contents.as_slice(), b"hello, world!");
}
#[test]
fn from_snapshot_dir() {
let vfs = Vfs::new(NoopFetcher);
let dir = VfsSnapshot::dir(hashmap! {
"a.txt" => VfsSnapshot::file("contents of a.txt"),
"b.lua" => VfsSnapshot::file("contents of b.lua"),
});
vfs.debug_load_snapshot("/dir", dir);
let children = vfs.get_children("/dir").unwrap();
let mut has_a = false;
let mut has_b = false;
for child in children.into_iter() {
if child.path() == Path::new("/dir/a.txt") {
has_a = true;
} else if child.path() == Path::new("/dir/b.lua") {
has_b = true;
} else {
panic!("Unexpected child in /dir");
}
}
assert!(has_a, "/dir/a.txt was missing");
assert!(has_b, "/dir/b.lua was missing");
let a_contents = vfs.get_contents("/dir/a.txt").unwrap();
assert_eq!(a_contents.as_slice(), b"contents of a.txt");
let b_contents = vfs.get_contents("/dir/b.lua").unwrap();
assert_eq!(b_contents.as_slice(), b"contents of b.lua");
}
#[test]
fn changed_event() {
#[derive(Default)]
struct MockState {
a_contents: &'static str,
}
struct MockFetcher {
inner: Rc<RefCell<MockState>>,
}
impl VfsFetcher for MockFetcher {
fn file_type(&self, path: &Path) -> io::Result<FileType> {
if path == Path::new("/dir/a.txt") {
return Ok(FileType::File);
}
unimplemented!();
}
fn read_contents(&self, path: &Path) -> io::Result<Vec<u8>> {
if path == Path::new("/dir/a.txt") {
let inner = self.inner.borrow();
return Ok(Vec::from(inner.a_contents));
}
unimplemented!();
}
fn read_children(&self, _path: &Path) -> io::Result<Vec<PathBuf>> {
unimplemented!();
}
fn create_directory(&self, _path: &Path) -> io::Result<()> {
unimplemented!();
}
fn write_file(&self, _path: &Path, _contents: &[u8]) -> io::Result<()> {
unimplemented!();
}
fn remove(&self, _path: &Path) -> io::Result<()> {
unimplemented!();
}
fn receiver(&self) -> Receiver<VfsEvent> {
crossbeam_channel::never()
}
}
let mock_state = Rc::new(RefCell::new(MockState {
a_contents: "Initial contents",
}));
let mut vfs = Vfs::new(MockFetcher {
inner: mock_state.clone(),
});
let a = vfs.get("/dir/a.txt").expect("mock file did not exist");
let contents = a.contents(&mut vfs).expect("mock file contents error");
assert_eq!(contents.as_slice(), b"Initial contents");
{
let mut mock_state = mock_state.borrow_mut();
mock_state.a_contents = "Changed contents";
}
vfs.commit_change(&VfsEvent::Modified(PathBuf::from("/dir/a.txt")))
.expect("error processing file change");
let contents = a.contents(&mut vfs).expect("mock file contents error");
assert_eq!(contents.as_slice(), b"Changed contents");
}
#[test]
fn removed_event_existing() {
let mut vfs = Vfs::new(NoopFetcher);
let file = VfsSnapshot::file("hello, world!");
vfs.debug_load_snapshot("/hello.txt", file);
let hello = vfs.get("/hello.txt").expect("couldn't get hello.txt");
let contents = hello
.contents(&mut vfs)
.expect("couldn't get hello.txt contents");
assert_eq!(contents.as_slice(), b"hello, world!");
vfs.commit_change(&VfsEvent::Removed(PathBuf::from("/hello.txt")))
.expect("error processing file removal");
match vfs.get("hello.txt") {
Err(ref err) if err.kind() == FsErrorKind::NotFound => {}
Ok(_) => {
panic!("hello.txt was not removed from Vfs");
}
Err(err) => {
panic!("Unexpected error: {:?}", err);
}
}
}
}

View File

@@ -1,7 +1,7 @@
//! Defines Rojo's HTTP API, all under /api. These endpoints generally return //! Defines Rojo's HTTP API, all under /api. These endpoints generally return
//! JSON. //! JSON.
use std::{collections::HashMap, sync::Arc}; use std::{collections::HashMap, fs, path::PathBuf, sync::Arc};
use futures::{Future, Stream}; use futures::{Future, Stream};
@@ -10,23 +10,22 @@ use rbx_dom_weak::RbxId;
use crate::{ use crate::{
serve_session::ServeSession, serve_session::ServeSession,
snapshot::{PatchSet, PatchUpdate}, snapshot::{InstanceWithMeta, PatchSet, PatchUpdate},
vfs::VfsFetcher,
web::{ web::{
interface::{ interface::{
ErrorResponse, Instance, InstanceMetadata as WebInstanceMetadata, InstanceUpdate, ErrorResponse, Instance, InstanceMetadata as WebInstanceMetadata, InstanceUpdate,
ReadResponse, ServerInfoResponse, SubscribeMessage, SubscribeResponse, WriteRequest, OpenResponse, ReadResponse, ServerInfoResponse, SubscribeMessage, SubscribeResponse,
WriteResponse, PROTOCOL_VERSION, SERVER_VERSION, WriteRequest, WriteResponse, PROTOCOL_VERSION, SERVER_VERSION,
}, },
util::{json, json_ok}, util::{json, json_ok},
}, },
}; };
pub struct ApiService<F> { pub struct ApiService {
serve_session: Arc<ServeSession<F>>, serve_session: Arc<ServeSession>,
} }
impl<F: VfsFetcher> Service for ApiService<F> { impl Service for ApiService {
type ReqBody = Body; type ReqBody = Body;
type ResBody = Body; type ResBody = Body;
type Error = hyper::Error; type Error = hyper::Error;
@@ -40,11 +39,12 @@ impl<F: VfsFetcher> Service for ApiService<F> {
(&Method::GET, path) if path.starts_with("/api/subscribe/") => { (&Method::GET, path) if path.starts_with("/api/subscribe/") => {
self.handle_api_subscribe(request) self.handle_api_subscribe(request)
} }
(&Method::POST, path) if path.starts_with("/api/open/") => {
(&Method::POST, "/api/write") if cfg!(feature = "unstable_two_way_sync") => { self.handle_api_open(request)
self.handle_api_write(request)
} }
(&Method::POST, "/api/write") => self.handle_api_write(request),
(_method, path) => json( (_method, path) => json(
ErrorResponse::not_found(format!("Route not found: {}", path)), ErrorResponse::not_found(format!("Route not found: {}", path)),
StatusCode::NOT_FOUND, StatusCode::NOT_FOUND,
@@ -53,8 +53,8 @@ impl<F: VfsFetcher> Service for ApiService<F> {
} }
} }
impl<F: VfsFetcher> ApiService<F> { impl ApiService {
pub fn new(serve_session: Arc<ServeSession<F>>) -> Self { pub fn new(serve_session: Arc<ServeSession>) -> Self {
ApiService { serve_session } ApiService { serve_session }
} }
@@ -234,4 +234,76 @@ impl<F: VfsFetcher> ApiService<F> {
instances, instances,
}) })
} }
/// Open a script with the given ID in the user's default text editor.
fn handle_api_open(&self, request: Request<Body>) -> <Self as Service>::Future {
let argument = &request.uri().path()["/api/open/".len()..];
let requested_id = match RbxId::parse_str(argument) {
Some(id) => id,
None => {
return json(
ErrorResponse::bad_request("Invalid instance ID"),
StatusCode::BAD_REQUEST,
);
}
};
let tree = self.serve_session.tree();
let instance = match tree.get_instance(requested_id) {
Some(instance) => instance,
None => {
return json(
ErrorResponse::bad_request("Instance not found"),
StatusCode::NOT_FOUND,
);
}
};
let script_path = match pick_script_path(instance) {
Some(path) => path,
None => {
return json(
ErrorResponse::bad_request(
"No appropriate file could be found to open this script",
),
StatusCode::NOT_FOUND,
);
}
};
let _ = opener::open(script_path);
json_ok(&OpenResponse {
session_id: self.serve_session.session_id(),
})
}
}
/// If this instance is represented by a script, try to find the correct .lua
/// file to open to edit it.
fn pick_script_path(instance: InstanceWithMeta<'_>) -> Option<PathBuf> {
match instance.class_name() {
"Script" | "LocalScript" | "ModuleScript" => {}
_ => return None,
}
// Pick the first listed relevant path that has an extension of .lua that
// exists.
instance
.metadata()
.relevant_paths
.iter()
.find(|path| {
// We should only ever open Lua files to be safe.
match path.extension().and_then(|ext| ext.to_str()) {
Some("lua") => {}
_ => return false,
}
fs::metadata(path)
.map(|meta| meta.is_file())
.unwrap_or(false)
})
.map(|path| path.to_owned())
} }

View File

@@ -128,6 +128,13 @@ pub struct SubscribeResponse<'a> {
pub messages: Vec<SubscribeMessage<'a>>, pub messages: Vec<SubscribeMessage<'a>>,
} }
/// Response body from /api/open/{id}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct OpenResponse {
pub session_id: SessionId,
}
/// General response type returned from all Rojo routes /// General response type returned from all Rojo routes
#[derive(Debug, Serialize, Deserialize)] #[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] #[serde(rename_all = "camelCase")]

View File

@@ -13,16 +13,16 @@ use futures::{
use hyper::{service::Service, Body, Request, Response, Server}; use hyper::{service::Service, Body, Request, Response, Server};
use log::trace; use log::trace;
use crate::{serve_session::ServeSession, vfs::VfsFetcher}; use crate::serve_session::ServeSession;
use self::{api::ApiService, ui::UiService}; use self::{api::ApiService, ui::UiService};
pub struct RootService<F> { pub struct RootService {
api: ApiService<F>, api: ApiService,
ui: UiService<F>, ui: UiService,
} }
impl<F: VfsFetcher> Service for RootService<F> { impl Service for RootService {
type ReqBody = Body; type ReqBody = Body;
type ResBody = Body; type ResBody = Body;
type Error = hyper::Error; type Error = hyper::Error;
@@ -39,8 +39,8 @@ impl<F: VfsFetcher> Service for RootService<F> {
} }
} }
impl<F: VfsFetcher> RootService<F> { impl RootService {
pub fn new(serve_session: Arc<ServeSession<F>>) -> Self { pub fn new(serve_session: Arc<ServeSession>) -> Self {
RootService { RootService {
api: ApiService::new(Arc::clone(&serve_session)), api: ApiService::new(Arc::clone(&serve_session)),
ui: UiService::new(Arc::clone(&serve_session)), ui: UiService::new(Arc::clone(&serve_session)),
@@ -48,12 +48,12 @@ impl<F: VfsFetcher> RootService<F> {
} }
} }
pub struct LiveServer<F> { pub struct LiveServer {
serve_session: Arc<ServeSession<F>>, serve_session: Arc<ServeSession>,
} }
impl<F: VfsFetcher + Send + Sync + 'static> LiveServer<F> { impl LiveServer {
pub fn new(serve_session: Arc<ServeSession<F>>) -> Self { pub fn new(serve_session: Arc<ServeSession>) -> Self {
LiveServer { serve_session } LiveServer { serve_session }
} }

View File

@@ -1,6 +1,6 @@
//! Defines the HTTP-based UI. These endpoints generally return HTML and SVG. //! Defines the HTTP-based UI. These endpoints generally return HTML and SVG.
use std::{borrow::Cow, path::Path, sync::Arc, time::Duration}; use std::{borrow::Cow, sync::Arc, time::Duration};
use futures::{future, Future}; use futures::{future, Future};
use hyper::{header, service::Service, Body, Method, Request, Response, StatusCode}; use hyper::{header, service::Service, Body, Method, Request, Response, StatusCode};
@@ -11,7 +11,6 @@ use ritz::{html, Fragment, HtmlContent, HtmlSelfClosingTag};
use crate::{ use crate::{
serve_session::ServeSession, serve_session::ServeSession,
snapshot::RojoTree, snapshot::RojoTree,
vfs::{Vfs, VfsDebug, VfsFetcher},
web::{ web::{
assets, assets,
interface::{ErrorResponse, SERVER_VERSION}, interface::{ErrorResponse, SERVER_VERSION},
@@ -19,11 +18,11 @@ use crate::{
}, },
}; };
pub struct UiService<F> { pub struct UiService {
serve_session: Arc<ServeSession<F>>, serve_session: Arc<ServeSession>,
} }
impl<F: VfsFetcher> Service for UiService<F> { impl Service for UiService {
type ReqBody = Body; type ReqBody = Body;
type ResBody = Body; type ResBody = Body;
type Error = hyper::Error; type Error = hyper::Error;
@@ -35,7 +34,6 @@ impl<F: VfsFetcher> Service for UiService<F> {
(&Method::GET, "/logo.png") => self.handle_logo(), (&Method::GET, "/logo.png") => self.handle_logo(),
(&Method::GET, "/icon.png") => self.handle_icon(), (&Method::GET, "/icon.png") => self.handle_icon(),
(&Method::GET, "/show-instances") => self.handle_show_instances(), (&Method::GET, "/show-instances") => self.handle_show_instances(),
(&Method::GET, "/show-vfs") => self.handle_show_vfs(),
(_method, path) => { (_method, path) => {
return json( return json(
ErrorResponse::not_found(format!("Route not found: {}", path)), ErrorResponse::not_found(format!("Route not found: {}", path)),
@@ -48,8 +46,8 @@ impl<F: VfsFetcher> Service for UiService<F> {
} }
} }
impl<F: VfsFetcher> UiService<F> { impl UiService {
pub fn new(serve_session: Arc<ServeSession<F>>) -> Self { pub fn new(serve_session: Arc<ServeSession>) -> Self {
UiService { serve_session } UiService { serve_session }
} }
@@ -71,7 +69,6 @@ impl<F: VfsFetcher> UiService<F> {
let page = self.normal_page(html! { let page = self.normal_page(html! {
<div class="button-list"> <div class="button-list">
{ Self::button("Rojo Documentation", "https://rojo.space/docs") } { Self::button("Rojo Documentation", "https://rojo.space/docs") }
{ Self::button("View virtual filesystem state", "/show-vfs") }
{ Self::button("View instance tree state", "/show-instances") } { Self::button("View instance tree state", "/show-instances") }
</div> </div>
}); });
@@ -96,100 +93,6 @@ impl<F: VfsFetcher> UiService<F> {
.unwrap() .unwrap()
} }
fn handle_show_vfs(&self) -> Response<Body> {
let vfs = self.serve_session.vfs();
let orphans: Vec<_> = vfs
.debug_orphans()
.into_iter()
.map(|path| Self::render_vfs_path(&vfs, &path, true))
.collect();
let watched_list: Vec<_> = vfs
.debug_watched_paths()
.into_iter()
.map(|path| {
html! {
<li>{ format!("{}", path.display()) }</li>
}
})
.collect();
let page = self.normal_page(html! {
<>
<section class="main-section">
<h1 class="section-title">"Known FS Items"</h1>
<div>{ Fragment::new(orphans) }</div>
</section>
<section class="main-section">
<h1 class="section-title">"Watched Paths"</h1>
<ul class="path-list">{ Fragment::new(watched_list) }</ul>
</section>
</>
});
Response::builder()
.header(header::CONTENT_TYPE, "text/html")
.body(Body::from(format!("<!DOCTYPE html>{}", page)))
.unwrap()
}
fn render_vfs_path(vfs: &Vfs<F>, path: &Path, is_root: bool) -> HtmlContent<'static> {
let is_file = vfs.debug_is_file(path);
let (note, children) = if is_file {
(HtmlContent::None, Vec::new())
} else {
let (is_exhaustive, mut children) = vfs.debug_children(path).unwrap();
// Sort files above directories, then sort how Path does after that.
children.sort_unstable_by(|a, b| {
let a_is_file = vfs.debug_is_file(a);
let b_is_file = vfs.debug_is_file(b);
b_is_file.cmp(&a_is_file).then_with(|| a.cmp(b))
});
let children: Vec<_> = children
.into_iter()
.map(|child| Self::render_vfs_path(vfs, &child, false))
.collect();
let note = if is_exhaustive {
HtmlContent::None
} else {
html!({ " (not enumerated)" })
};
(note, children)
};
// For root entries, we want the full path to contextualize the path.
let mut name = if is_root {
path.to_str().unwrap().to_owned()
} else {
path.file_name().unwrap().to_str().unwrap().to_owned()
};
// Directories should end with `/` in the UI to mark them.
if !is_file && !name.ends_with('/') && !name.ends_with('\\') {
name.push('/');
}
html! {
<div class="vfs-entry">
<div>
<span class="vfs-entry-name">{ name }</span>
<span class="vfs-entry-note">{ note }</span>
</div>
<div class="vfs-entry-children">
{ Fragment::new(children) }
</div>
</div>
}
}
fn instance(tree: &RojoTree, id: RbxId) -> HtmlContent<'_> { fn instance(tree: &RojoTree, id: RbxId) -> HtmlContent<'_> {
let instance = tree.get_instance(id).unwrap(); let instance = tree.get_instance(id).unwrap();
let children_list: Vec<_> = instance let children_list: Vec<_> = instance

View File

@@ -1,5 +0,0 @@
# [vfs]
Name pending. Implementation of a virtual filesystem with a configurable backend and file watching.
## License
[vfs] is available under the terms of the MIT license. See [LICENSE.txt](LICENSE.txt) or <https://opensource.org/licenses/MIT> for more details.