Compare commits

...

40 Commits

Author SHA1 Message Date
Lucien Greathouse
e17771a6a5 Release v7.3.0 2023-04-22 16:07:39 -04:00
Lucien Greathouse
bac30ae78b Update MSRV to try to fix CI workflow 2023-04-22 15:58:14 -04:00
Lucien Greathouse
c0219922b2 Update dependencies 2023-04-22 15:44:49 -04:00
boatbomber
b5ed952d5c Add visual diffs to syncing (#603)
* Add user confirmation to initial sync

* Use "Accept" instead of "Confirm"

* Draw tree alphabetically for determinism

* Add diff table dropdown

* Add diff table to newly added objects

* Unblock keybind workflow

* Only show reject button when two way is enabled

* Try to patch back to the files when changes are rejected

* Improve text spacing of the prop diff table

* Skip user confirmation of perfect syncs

* Give instances names for debugging UI

* Optimize tree building

* Efficiency: dynamic virtual scrolling & lazy rendering

* Simplify virtual scroller logic and avoid wasteful rerenders

* Remove debug print

* Consistent naming

* Move new patch applied callback into accept

* Pcall archivable

* Keybinds open popup diff window

* Theme rows in diff

* Remove relic of prototype

* Color value visuals and better component name

* changeBatcher is not needed when no sync is active

* Simplify popup roact entrypoint

* Alphabetical prop lists and refactor

* Add a stroke to color blot for contrast

* Make color blots animate transparency with the rest of the page

* StyLua formatting on newly added files

* Remove wasteful table

* Fix diffing custom properties

* Display tables more meaningfully

* Allow children in the button components

* Create a rough tooltip component

* Add tooltips to buttons

* Use provider+trigger schema to avoid tooltip ZIndex issues

* Add triangle point to tooltip

* Tooltip underneath instead of covering

* Cancel hovers when unmounting

* Allow multiple canvases from one provider

* Display above or below depending on available space

* Move patch equality to PatchSet.isEqual

* Use Container

* Remove old submodules

* Reduce false positives in diff

* Add debug log

* Fuzzy equals CFrame in diffs to avoid floating point in

* Fix decodeValue usage

* Support the .changedName patches

* Fix content overlapping border

* Fix tooltip tail alignment

* Fix tooltip text fit

* Whoops, fix it properly

* Move PatchVisualizer to Components

* Provide Connected info with full patch data

* Avoid implicit nil return

* Add patch visualizer to connected page

* Make Current column invisible when visualizing applied patches

* Avoid floating point diffs in a numbers and vectors
2023-04-01 23:17:23 -04:00
ok-nick
7994bc4909 Update setup-aftman (#648) 2022-11-18 03:32:13 -05:00
boatbomber
b88d34c639 Add tooltips to buttons (#637)
* Add tooltips

* Fix whitespace

* Avoid overloaded word canvas

* Clean render function

* Switch folder to fragment
2022-10-07 19:31:14 -04:00
fox
96cb1ee3fd Support explicitly specifying http or https protocol in plugin (#642)
* Support explicitly specifying http or https protocol in plugin

* Fix incorrect format string

Port is not a number
2022-09-30 17:59:09 -04:00
boatbomber
003abe86bb Save host and port by placeId (#613)
* Save host and port by placeId

* Bump to 5 months before clearing

* Fix indentation
2022-09-22 23:03:09 -04:00
Lucien Greathouse
6ec411a618 Add Patreon badge to README 2022-08-20 23:44:10 -04:00
Qualadore
c7c0903804 Reduce minimum plugin size (#606)
* Reduce minimum plugin size

* Resize to 300x120

Co-authored-by: Qualadore <me@qualadore.com>
2022-08-20 22:40:52 -04:00
boatbomber
cdc972a5ce Migrate DevSettings to PluginSettings for much better config flow (#572)
* Add the devsetting config options into settings

* Create dropdown component and add setting controls

* Static dropdwon width and spin arrow

* Improve dropdown option contrast and border

* Forgot to make the settings page respect the static spacing, oops

* Smaller arrow

* Vert padding

* Reset option for settings

* Hide reset button when on default

* Respect the logLevel setting

* Portal settings out to external typechecking module

* Implement new configs using the new singleton Settings

* Remove DevSettings

* Update test runner to use new settings

* More helpful test failure output

* Support non-plugin environment

* Migrate dropdown to new packages system

* Clean up components a tad
2022-08-20 22:39:34 -04:00
Boegie19
17de912608 fix_vfs_double_update (#616) 2022-08-20 22:33:19 -04:00
Boegie19
9876508887 added attributes to AdjacentMetadata (#624)
* added attributes to AdjacentMetadata

* ran fmt
2022-08-20 22:32:58 -04:00
Lucien Greathouse
72d62220e8 Fix referring to open source maintainer as a chicken 2022-08-20 22:22:11 -04:00
Lucien Greathouse
46ad337fa5 Switch all workflows to Aftman 2022-08-20 22:20:51 -04:00
Boegie19
7a3ba7721f fix release action with aftman (#627)
* fix release action with aftman

* Fixes using bash not powershell

* removed comment
2022-08-20 22:15:01 -04:00
Lucien Greathouse
e0198e626b Build Linux release on Ubuntu 20.04, use fixed artifact names 2022-08-20 21:34:41 -04:00
boatbomber
142705f386 Fix security permission error (#619) 2022-08-10 15:57:24 -04:00
boatbomber
4cb49c7825 Add sync locking for Team Create (#590)
* Add sync locking

* Steal lock from users who left without releasing

* Do not remove lock as unknown instance

* Don't delete non Archivable instance
2022-08-08 04:08:55 -04:00
Barocena
05adb82dda Renamed Common to Shared (#611) 2022-08-08 03:59:52 -04:00
boatbomber
faf7671799 Make error messages copyable (#614)
* Make error copyable

* Allow partial copying or double click full copy
2022-08-08 03:58:32 -04:00
Lucien Greathouse
d64db329dd Fix release workflow to use Wally 2022-08-08 00:28:52 -04:00
Lucien Greathouse
e34d2339ad Vendor OpenSSL via native-tls-vendored reqwest feature 2022-08-08 00:15:05 -04:00
Max
d196c5091c Simplify usage of attributes. (#574)
* Support implicit values for primitive attributes

This commit adds support for strings, numbers, and booleans to be implicitly typed in attribute maps, reducing the redundancy of needing to specify their types.

I also quietly adjusted one of the tests to use a more stable class/property pair. Since SourceAssetId is locked to Roblox, it could potentially disappear at any time.

* Apply formatting.

* Address feedback

* Backwards compatible format usage.

* Axe UnresolvedValueMap in favor of $attributes

Attributes can be defined directly on instances, with support for unambiguous types.

* Adjust test.

* to_string() -> into()

* Made attribute test more concise.

* small cleanup

* Update src/resolution.rs

* Update src/resolution.rs

* Update src/resolution.rs

* Update src/resolution.rs

Co-authored-by: Lucien Greathouse <me@lpghatguy.com>
2022-08-03 20:07:06 -04:00
Lucien Greathouse
3e83f92532 Update MSRV to 1.58.1 for format string capturing 2022-08-03 19:39:36 -04:00
James Onnen
41d7aaf323 Add uipadding to notifications (#589) 2022-08-03 19:01:07 -04:00
boatbomber
e110f3726a Real-time status about sync details (#569)
* Rough prototype of patch info display

* Remove extra newline

* Switch to binding

* Update slower for older timestamps

* Batch patches within a second of each other

* Fix indentation

* Less wasteful refresh hz

* More apt variable name

Co-authored-by: Lucien Greathouse <me@lpghatguy.com>
2022-08-03 18:58:28 -04:00
Boegie19
eb5c897ac0 fix relevant_paths not being set for init.csv (#599)
* fix relevant_paths not being set for init.csv

* fix failing tests

Co-authored-by: Lucien Greathouse <me@lpghatguy.com>
2022-08-03 18:38:08 -04:00
boatbomber
e864cf0c7d Switch git submodules to Wally packages (#584)
* Switch git submodules to Wally packages

* Update build snapshot

* Add wally to foreman and use latest versions

* Install packages in CI runners

* Fix indents

* Install packages in the correct directory

* Install packages in correct dir of release action too

* Remove submodules from ci checkout

* Remove submodules from release checkout

* Update selene with latest fix

* Fix whitespace

Co-authored-by: Lucien Greathouse <me@lpghatguy.com>
2022-08-03 18:36:58 -04:00
Lucien Greathouse
565c12405e Skip empty AppliedPatchSets for sending changes. 2022-08-03 17:19:23 -04:00
JohnnyMorganz
2a6a8b42a6 Add --watch to sourcemap generation (#602)
* Implement watch argument

* Add forget call

* Clippy fixes

* Update changelog
2022-08-01 04:07:07 -04:00
Boegie19
5cb4cc0d1d feature init csv (#594)
* init csv feature + test

* fmt fixes
2022-07-29 21:45:19 -04:00
boatbomber
62eb4f026f Fix errors after session already ended (#587) 2022-07-23 12:24:16 -04:00
wackbyte
411d1a89c1 Really default to the current directory in 'rojo fmt-project' (#581)
Co-authored-by: Lucien Greathouse <me@lpghatguy.com>
2022-07-18 19:47:30 -04:00
boatbomber
6ae0bf366a Use singleton settings outside the Roact tree (#576)
* Use singleton settings outside the Roact tree

* Cleanup listener on unmount

* Refactor setting page components

* Fix willUnmount being added to the wrong table

* Remove bindings in favor of state
2022-07-18 19:36:38 -04:00
wackbyte
178cdc9dfa Update benches so they compile (#582) 2022-07-17 18:50:12 -04:00
wackbyte
5bf1f86886 Fix link to v7.2.1 in the changelog (#578) 2022-07-11 00:44:47 -04:00
Lucien Greathouse
e482aba030 Release v7.2.1 2022-07-08 20:22:16 -04:00
Samuel P
535e4d42bb Change Notification sound to generic sound (#566)
* Change Notification sound to generic sound

The notification sound causes the game to summon an error due to no experience permissions with no way to grant permission. This is due to the new audio policy update.

* Update Notification sound
2022-07-02 19:33:24 -04:00
boatbomber
54398d4c4b Add setting to toggle sound effects (#568)
* Use soundPlayer object with setting

* Style changes
2022-07-02 05:12:58 -04:00
113 changed files with 10516 additions and 2183 deletions

View File

@@ -16,12 +16,10 @@ jobs:
strategy: strategy:
matrix: matrix:
rust_version: [stable, 1.57.0] rust_version: [stable, 1.69.0]
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
with:
submodules: true
- name: Install Rust - name: Install Rust
uses: actions-rs/toolchain@v1 uses: actions-rs/toolchain@v1
@@ -30,6 +28,17 @@ jobs:
override: true override: true
profile: minimal profile: minimal
- name: Setup Aftman
uses: ok-nick/setup-aftman@v0.3.0
with:
version: 'v0.2.7'
- name: Install packages
run: |
cd plugin
wally install
cd ..
- name: Build - name: Build
run: cargo build --locked --verbose run: cargo build --locked --verbose
@@ -42,8 +51,6 @@ jobs:
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
with:
submodules: true
- name: Install Rust - name: Install Rust
uses: actions-rs/toolchain@v1 uses: actions-rs/toolchain@v1
@@ -52,6 +59,17 @@ jobs:
override: true override: true
components: rustfmt, clippy components: rustfmt, clippy
- name: Setup Aftman
uses: ok-nick/setup-aftman@v0.3.0
with:
version: 'v0.2.7'
- name: Install packages
run: |
cd plugin
wally install
cd ..
- name: Rustfmt - name: Rustfmt
run: cargo fmt -- --check run: cargo fmt -- --check

View File

@@ -28,13 +28,19 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
with:
submodules: true
- name: Setup Foreman - name: Setup Aftman
uses: Roblox/setup-foreman@v1 uses: ok-nick/setup-aftman@v0.1.0
with: with:
token: ${{ secrets.GITHUB_TOKEN }} token: ${{ secrets.GITHUB_TOKEN }}
trust-check: false
version: 'v0.2.6'
- name: Install packages
run: |
cd plugin
wally install
cd ..
- name: Build Plugin - name: Build Plugin
run: rojo build plugin --output Rojo.rbxm run: rojo build plugin --output Rojo.rbxm
@@ -61,25 +67,21 @@ jobs:
fail-fast: false fail-fast: false
matrix: matrix:
# https://doc.rust-lang.org/rustc/platform-support.html # https://doc.rust-lang.org/rustc/platform-support.html
#
# FIXME: After the Rojo VS Code extension updates, add architecture
# names to each of these releases. We'll rename win64 to windows and add
# -x86_64 to each release.
include: include:
- host: linux - host: linux
os: ubuntu-18.04 os: ubuntu-20.04
target: x86_64-unknown-linux-gnu target: x86_64-unknown-linux-gnu
label: linux label: linux-x86_64
- host: windows - host: windows
os: windows-latest os: windows-latest
target: x86_64-pc-windows-msvc target: x86_64-pc-windows-msvc
label: win64 label: windows-x86_64
- host: macos - host: macos
os: macos-latest os: macos-latest
target: x86_64-apple-darwin target: x86_64-apple-darwin
label: macos label: macos-x86_64
- host: macos - host: macos
os: macos-latest os: macos-latest
@@ -92,8 +94,6 @@ jobs:
BIN: rojo BIN: rojo
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v3
with:
submodules: true
- name: Get Version from Tag - name: Get Version from Tag
shell: bash shell: bash
@@ -110,6 +110,20 @@ jobs:
override: true override: true
profile: minimal profile: minimal
- name: Setup Aftman
uses: ok-nick/setup-aftman@v0.1.0
with:
token: ${{ secrets.GITHUB_TOKEN }}
trust-check: false
version: 'v0.2.6'
- name: Install packages
run: |
cd plugin
wally install
cd ..
shell: bash
- name: Build Release - name: Build Release
run: cargo build --release --locked --verbose run: cargo build --release --locked --verbose
env: env:
@@ -150,4 +164,4 @@ jobs:
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
with: with:
name: ${{ env.BIN }}-${{ env.PROJECT_VERSION }}-${{ matrix.label }}.zip name: ${{ env.BIN }}-${{ env.PROJECT_VERSION }}-${{ matrix.label }}.zip
path: release.zip path: release.zip

3
.gitignore vendored
View File

@@ -13,6 +13,9 @@
# Test places for the Roblox Studio Plugin # Test places for the Roblox Studio Plugin
/plugin/*.rbxlx /plugin/*.rbxlx
# Packages for the Roblox Studio Plugin
/plugin/*Packages
# Roblox Studio holds 'lock' files on places # Roblox Studio holds 'lock' files on places
*.rbxl.lock *.rbxl.lock
*.rbxlx.lock *.rbxlx.lock

15
.gitmodules vendored
View File

@@ -1,15 +0,0 @@
[submodule "plugin/modules/roact"]
path = plugin/modules/roact
url = https://github.com/Roblox/roact.git
[submodule "plugin/modules/testez"]
path = plugin/modules/testez
url = https://github.com/Roblox/testez.git
[submodule "plugin/modules/promise"]
path = plugin/modules/promise
url = https://github.com/LPGhatguy/roblox-lua-promise.git
[submodule "plugin/modules/t"]
path = plugin/modules/t
url = https://github.com/osyrisrblx/t.git
[submodule "plugin/modules/flipper"]
path = plugin/modules/flipper
url = https://github.com/Reselim/Flipper

View File

@@ -2,6 +2,50 @@
## Unreleased Changes ## Unreleased Changes
## [7.3.0] - April 22, 2023
* Added `$attributes` to project format. ([#574])
* Added `--watch` flag to `rojo sourcemap`. ([#602])
* Added support for `init.csv` files. ([#594])
* Added real-time sync status to the Studio plugin. ([#569])
* Added support for copying error messages to the clipboard. ([#614])
* Added sync locking for Team Create. ([#590])
* Added support for specifying HTTP or HTTPS protocol in plugin. ([#642])
* Added tooltips to buttons in the Studio plugin. ([#637])
* Added visual diffs when connecting from the Studio plugin. ([#603])
* Host and port are now saved in the Studio plugin. ([#613])
* Improved padding on notifications in Studio plugin. ([#589])
* Renamed `Common` to `Shared` in the default Rojo project. ([#611])
* Reduced the minimum size of the Studio plugin widget. ([#606])
* Fixed current directory in `rojo fmt-project`. ([#581])
* Fixed errors after a session has already ended. ([#587])
* Fixed an uncommon security permission error ([#619])
[#569]: https://github.com/rojo-rbx/rojo/pull/569
[#574]: https://github.com/rojo-rbx/rojo/pull/574
[#581]: https://github.com/rojo-rbx/rojo/pull/581
[#587]: https://github.com/rojo-rbx/rojo/pull/587
[#589]: https://github.com/rojo-rbx/rojo/pull/589
[#590]: https://github.com/rojo-rbx/rojo/pull/590
[#594]: https://github.com/rojo-rbx/rojo/pull/594
[#602]: https://github.com/rojo-rbx/rojo/pull/602
[#603]: https://github.com/rojo-rbx/rojo/pull/603
[#606]: https://github.com/rojo-rbx/rojo/pull/606
[#611]: https://github.com/rojo-rbx/rojo/pull/611
[#613]: https://github.com/rojo-rbx/rojo/pull/613
[#614]: https://github.com/rojo-rbx/rojo/pull/614
[#619]: https://github.com/rojo-rbx/rojo/pull/619
[#637]: https://github.com/rojo-rbx/rojo/pull/637
[#642]: https://github.com/rojo-rbx/rojo/pull/642
[7.3.0]: https://github.com/rojo-rbx/rojo/releases/tag/v7.3.0
## [7.2.1] - July 8, 2022
* Fixed notification sound by changing it to a generic sound. ([#566])
* Added setting to turn off sound effects. ([#568])
[#566]: https://github.com/rojo-rbx/rojo/pull/566
[#568]: https://github.com/rojo-rbx/rojo/pull/568
[7.2.1]: https://github.com/rojo-rbx/rojo/releases/tag/v7.2.1
## [7.2.0] - June 29, 2022 ## [7.2.0] - June 29, 2022
* Added support for `.luau` files. ([#552]) * Added support for `.luau` files. ([#552])
* Added support for live syncing Attributes and Tags. ([#553]) * Added support for live syncing Attributes and Tags. ([#553])

1244
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
[package] [package]
name = "rojo" name = "rojo"
version = "7.2.0" version = "7.3.0"
rust-version = "1.57.0" rust-version = "1.68.2"
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"
@@ -51,11 +51,11 @@ memofs = { version = "0.2.0", path = "crates/memofs" }
# rbx_reflection_database = { path = "../rbx-dom/rbx_reflection_database" } # rbx_reflection_database = { path = "../rbx-dom/rbx_reflection_database" }
# rbx_xml = { path = "../rbx-dom/rbx_xml" } # rbx_xml = { path = "../rbx-dom/rbx_xml" }
rbx_binary = "0.6.5" rbx_binary = "0.7.0"
rbx_dom_weak = "2.4.0" rbx_dom_weak = "2.4.0"
rbx_reflection = "4.2.0" rbx_reflection = "4.2.0"
rbx_reflection_database = "0.2.2" rbx_reflection_database = "0.2.6"
rbx_xml = "0.12.3" rbx_xml = "0.13.0"
anyhow = "1.0.44" anyhow = "1.0.44"
backtrace = "0.3.61" backtrace = "0.3.61"
@@ -73,7 +73,7 @@ log = "0.4.14"
maplit = "1.0.2" maplit = "1.0.2"
notify = "4.0.17" notify = "4.0.17"
opener = "0.5.0" opener = "0.5.0"
reqwest = { version = "0.11.10", features = ["blocking", "json"] } reqwest = { version = "0.11.10", features = ["blocking", "json", "native-tls-vendored"] }
ritz = "0.1.0" ritz = "0.1.0"
roblox_install = "1.0.0" roblox_install = "1.0.0"
serde = { version = "1.0.130", features = ["derive", "rc"] } serde = { version = "1.0.130", features = ["derive", "rc"] }
@@ -102,7 +102,7 @@ maplit = "1.0.2"
rojo-insta-ext = { path = "crates/rojo-insta-ext" } rojo-insta-ext = { path = "crates/rojo-insta-ext" }
criterion = "0.3.5" criterion = "0.3.5"
insta = { version = "1.8.0", features = ["redactions"] } insta = { version = "1.8.0", features = ["redactions", "yaml"] }
paste = "1.0.5" paste = "1.0.5"
pretty_assertions = "1.2.1" pretty_assertions = "1.2.1"
serde_yaml = "0.8.21" serde_yaml = "0.8.21"

View File

@@ -8,6 +8,7 @@
<a href="https://github.com/rojo-rbx/rojo/actions"><img src="https://github.com/rojo-rbx/rojo/workflows/CI/badge.svg" alt="Actions status" /></a> <a href="https://github.com/rojo-rbx/rojo/actions"><img src="https://github.com/rojo-rbx/rojo/workflows/CI/badge.svg" alt="Actions status" /></a>
<a href="https://crates.io/crates/rojo"><img src="https://img.shields.io/crates/v/rojo.svg?label=latest%20release" alt="Latest server version" /></a> <a href="https://crates.io/crates/rojo"><img src="https://img.shields.io/crates/v/rojo.svg?label=latest%20release" alt="Latest server version" /></a>
<a href="https://rojo.space/docs"><img src="https://img.shields.io/badge/docs-website-brightgreen.svg" alt="Rojo Documentation" /></a> <a href="https://rojo.space/docs"><img src="https://img.shields.io/badge/docs-website-brightgreen.svg" alt="Rojo Documentation" /></a>
<a href="https://www.patreon.com/lpghatguy"><img src="https://img.shields.io/badge/sponsor-patreon-red" alt="Patreon" /></a>
</div> </div>
<hr /> <hr />
@@ -40,7 +41,7 @@ Check out our [contribution guide](CONTRIBUTING.md) for detailed instructions fo
Pull requests are welcome! Pull requests are welcome!
Rojo supports Rust 1.57.0 and newer. The minimum supported version of Rust is based on the latest versions of the dependencies that Rojo has. Rojo supports Rust 1.58.1 and newer. The minimum supported version of Rust is based on the latest versions of the dependencies that Rojo has.
## License ## License
Rojo is available under the terms of the Mozilla Public License, Version 2.0. See [LICENSE.txt](LICENSE.txt) for details. Rojo is available under the terms of the Mozilla Public License, Version 2.0. See [LICENSE.txt](LICENSE.txt) for details.

5
aftman.toml Normal file
View File

@@ -0,0 +1,5 @@
[tools]
wally = "UpliftGames/wally@0.3.1"
rojo = "rojo-rbx/rojo@7.2.1"
selene = "Kampfkarren/selene@0.20.0"
run-in-roblox = "rojo-rbx/run-in-roblox@0.3.0"

View File

@@ -4,7 +4,7 @@
"$className": "DataModel", "$className": "DataModel",
"ReplicatedStorage": { "ReplicatedStorage": {
"Common": { "Shared": {
"$path": "src/shared" "$path": "src/shared"
} }
}, },

View File

@@ -3,7 +3,7 @@ use std::path::Path;
use criterion::{criterion_group, criterion_main, BatchSize, Criterion}; use criterion::{criterion_group, criterion_main, BatchSize, Criterion};
use tempfile::{tempdir, TempDir}; use tempfile::{tempdir, TempDir};
use librojo::cli::{build, BuildCommand}; use librojo::cli::BuildCommand;
pub fn benchmark_small_place(c: &mut Criterion) { pub fn benchmark_small_place(c: &mut Criterion) {
bench_build_place(c, "Small Place", "test-projects/benchmark_small_place") bench_build_place(c, "Small Place", "test-projects/benchmark_small_place")
@@ -20,7 +20,7 @@ fn bench_build_place(c: &mut Criterion, name: &str, path: &str) {
group.bench_function("build", |b| { group.bench_function("build", |b| {
b.iter_batched( b.iter_batched(
|| place_setup(path), || place_setup(path),
|(_dir, options)| build(options).unwrap(), |(_dir, options)| options.run().unwrap(),
BatchSize::SmallInput, BatchSize::SmallInput,
) )
}); });

View File

@@ -43,8 +43,6 @@ fn main() -> Result<(), anyhow::Error> {
let root_dir = env::var_os("CARGO_MANIFEST_DIR").unwrap(); let root_dir = env::var_os("CARGO_MANIFEST_DIR").unwrap();
let plugin_root = PathBuf::from(root_dir).join("plugin"); let plugin_root = PathBuf::from(root_dir).join("plugin");
let plugin_modules = plugin_root.join("modules");
let snapshot = VfsSnapshot::dir(hashmap! { let snapshot = VfsSnapshot::dir(hashmap! {
"default.project.json" => snapshot_from_fs_path(&plugin_root.join("default.project.json"))?, "default.project.json" => snapshot_from_fs_path(&plugin_root.join("default.project.json"))?,
"fmt" => snapshot_from_fs_path(&plugin_root.join("fmt"))?, "fmt" => snapshot_from_fs_path(&plugin_root.join("fmt"))?,
@@ -52,20 +50,7 @@ fn main() -> Result<(), anyhow::Error> {
"log" => snapshot_from_fs_path(&plugin_root.join("log"))?, "log" => snapshot_from_fs_path(&plugin_root.join("log"))?,
"rbx_dom_lua" => snapshot_from_fs_path(&plugin_root.join("rbx_dom_lua"))?, "rbx_dom_lua" => snapshot_from_fs_path(&plugin_root.join("rbx_dom_lua"))?,
"src" => snapshot_from_fs_path(&plugin_root.join("src"))?, "src" => snapshot_from_fs_path(&plugin_root.join("src"))?,
"modules" => VfsSnapshot::dir(hashmap! { "Packages" => snapshot_from_fs_path(&plugin_root.join("Packages"))?,
"roact" => VfsSnapshot::dir(hashmap! {
"src" => snapshot_from_fs_path(&plugin_modules.join("roact").join("src"))?
}),
"promise" => VfsSnapshot::dir(hashmap! {
"lib" => snapshot_from_fs_path(&plugin_modules.join("promise").join("lib"))?
}),
"t" => VfsSnapshot::dir(hashmap! {
"lib" => snapshot_from_fs_path(&plugin_modules.join("t").join("lib"))?
}),
"flipper" => VfsSnapshot::dir(hashmap! {
"src" => snapshot_from_fs_path(&plugin_modules.join("flipper").join("src"))?
}),
}),
}); });
let out_path = Path::new(&out_dir).join("plugin.bincode"); let out_path = Path::new(&out_dir).join("plugin.bincode");

View File

@@ -1,4 +0,0 @@
[tools]
rojo = { source = "rojo-rbx/rojo", version = "7.1.1" }
run-in-roblox = { source = "rojo-rbx/run-in-roblox", version = "0.3.0" }
selene = { source = "Kampfkarren/selene", version = "0.18.2" }

View File

@@ -1,33 +1,25 @@
{ {
"name": "Rojo", "name": "Rojo",
"tree": { "tree": {
"$className": "Folder", "$className": "Folder",
"Plugin": { "Plugin": {
"$path": "src" "$path": "src"
}, },
"Log": { "Packages": {
"$path": "log" "$path": "Packages",
},
"Http": { "Log": {
"$path": "http" "$path": "log"
}, },
"Fmt": { "Http": {
"$path": "fmt" "$path": "http"
}, },
"RbxDom": { "Fmt": {
"$path": "rbx_dom_lua" "$path": "fmt"
}, },
"Roact": { "RbxDom": {
"$path": "modules/roact/src" "$path": "rbx_dom_lua"
}, }
"Promise": { }
"$path": "modules/promise/lib" }
}, }
"t": {
"$path": "modules/t/lib"
},
"Flipper": {
"$path": "modules/flipper/src"
}
}
}

Submodule plugin/modules/t deleted from f643b50682

View File

@@ -238,6 +238,23 @@ types = {
toPod = serializeFloat, toPod = serializeFloat,
}, },
Font = {
fromPod = function(pod)
return Font.new(
pod.family,
if pod.weight ~= nil then Enum.FontWeight[pod.weight] else nil,
if pod.style ~= nil then Enum.FontStyle[pod.style] else nil
)
end,
toPod = function(roblox)
return {
family = roblox.Family,
weight = roblox.Weight.Name,
style = roblox.Style.Name,
}
end,
},
Int32 = { Int32 = {
fromPod = identity, fromPod = identity,
toPod = identity, toPod = identity,

View File

@@ -53,6 +53,11 @@ function PropertyDescriptor:read(instance)
end end
if self.scriptability == "Custom" then if self.scriptability == "Custom" then
if customProperties[self.className] == nil then
local fullName = ("%s.%s"):format(instance.className, self.name)
return false, Error.new(Error.Kind.PropertyNotReadable, fullName)
end
local interface = customProperties[self.className][self.name] local interface = customProperties[self.className][self.name]
return interface.read(instance, self.name) return interface.read(instance, self.name)
@@ -79,6 +84,11 @@ function PropertyDescriptor:write(instance, value)
end end
if self.scriptability == "Custom" then if self.scriptability == "Custom" then
if customProperties[self.className] == nil then
local fullName = ("%s.%s"):format(instance.className, self.name)
return false, Error.new(Error.Kind.PropertyNotWritable, fullName)
end
local interface = customProperties[self.className][self.name] local interface = customProperties[self.className][self.name]
return interface.write(instance, self.name, value) return interface.write(instance, self.name, value)

View File

@@ -207,6 +207,17 @@
}, },
"ty": "Float64" "ty": "Float64"
}, },
"Font": {
"value": {
"Font": {
"family": "rbxasset://fonts/families/SourceSansPro.json",
"weight": "Regular",
"style": "Normal",
"cachedFaceId": null
}
},
"ty": "Font"
},
"Int32": { "Int32": {
"value": { "value": {
"Int32": 6014 "Int32": 6014

View File

@@ -61,4 +61,14 @@ return {
end, end,
}, },
}, },
Model = {
Scale = {
read = function(instance, _, _)
return true, instance:GetScale()
end,
write = function(instance, _, value)
return true, instance:ScaleTo(value)
end,
},
},
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1,19 +1,11 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage") local ReplicatedStorage = game:GetService("ReplicatedStorage")
local TestEZ = require(ReplicatedStorage.TestEZ) local TestEZ = require(ReplicatedStorage.Packages.TestEZ)
local Rojo = ReplicatedStorage.Rojo local Rojo = ReplicatedStorage.Rojo
local DevSettings = require(Rojo.Plugin.DevSettings) local Settings = require(Rojo.Plugin.Settings)
Settings:set("logLevel", "Trace")
local setDevSettings = not DevSettings:hasChangedValues() Settings:set("typecheckingEnabled", true)
if setDevSettings then
DevSettings:createTestSettings()
end
require(Rojo.Plugin.runTests)(TestEZ) require(Rojo.Plugin.runTests)(TestEZ)
if setDevSettings then
DevSettings:resetValues()
end

View File

@@ -1,6 +1,7 @@
local Http = require(script.Parent.Parent.Http) local Packages = script.Parent.Parent.Packages
local Log = require(script.Parent.Parent.Log) local Http = require(Packages.Http)
local Promise = require(script.Parent.Parent.Promise) local Log = require(Packages.Log)
local Promise = require(Packages.Promise)
local Config = require(script.Parent.Config) local Config = require(script.Parent.Config)
local Types = require(script.Parent.Types) local Types = require(script.Parent.Types)
@@ -85,7 +86,7 @@ local ApiContext = {}
ApiContext.__index = ApiContext ApiContext.__index = ApiContext
function ApiContext.new(baseUrl) function ApiContext.new(baseUrl)
assert(type(baseUrl) == "string") assert(type(baseUrl) == "string", "baseUrl must be a string")
local self = { local self = {
__baseUrl = baseUrl, __baseUrl = baseUrl,
@@ -248,4 +249,4 @@ function ApiContext:open(id)
end) end)
end end
return ApiContext return ApiContext

View File

@@ -1,7 +1,8 @@
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Rojo.Roact) local Roact = require(Packages.Roact)
local Theme = require(Plugin.App.Theme) local Theme = require(Plugin.App.Theme)
local Assets = require(Plugin.Assets) local Assets = require(Plugin.Assets)
@@ -23,8 +24,10 @@ local function BorderedContainer(props)
layoutOrder = props.layoutOrder, layoutOrder = props.layoutOrder,
}, { }, {
Content = e("Frame", { Content = e("Frame", {
Size = UDim2.new(1, 0, 1, 0), Size = UDim2.new(1, -2, 1, -2),
Position = UDim2.new(0, 1, 0, 1),
BackgroundTransparency = 1, BackgroundTransparency = 1,
ZIndex = 2,
}, props[Roact.Children]), }, props[Roact.Children]),
Border = e(SlicedImage, { Border = e(SlicedImage, {
@@ -38,4 +41,4 @@ local function BorderedContainer(props)
end) end)
end end
return BorderedContainer return BorderedContainer

View File

@@ -1,14 +1,16 @@
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Rojo.Roact) local Roact = require(Packages.Roact)
local Flipper = require(Rojo.Flipper) local Flipper = require(Packages.Flipper)
local Assets = require(Plugin.Assets) local Assets = require(Plugin.Assets)
local Theme = require(Plugin.App.Theme) local Theme = require(Plugin.App.Theme)
local bindingUtil = require(Plugin.App.bindingUtil) local bindingUtil = require(Plugin.App.bindingUtil)
local SlicedImage = require(script.Parent.SlicedImage) local SlicedImage = require(script.Parent.SlicedImage)
local Tooltip = require(script.Parent.Tooltip)
local e = Roact.createElement local e = Roact.createElement
@@ -51,6 +53,10 @@ function Checkbox:render()
[Roact.Event.Activated] = self.props.onClick, [Roact.Event.Activated] = self.props.onClick,
}, { }, {
StateTip = e(Tooltip.Trigger, {
text = if self.props.active then "Enabled" else "Disabled",
}),
Active = e(SlicedImage, { Active = e(SlicedImage, {
slice = Assets.Slices.RoundedBackground, slice = Assets.Slices.RoundedBackground,
color = theme.Active.BackgroundColor, color = theme.Active.BackgroundColor,
@@ -93,4 +99,4 @@ function Checkbox:render()
end) end)
end end
return Checkbox return Checkbox

View File

@@ -0,0 +1,169 @@
local TextService = game:GetService("TextService")
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Flipper = require(Packages.Flipper)
local Assets = require(Plugin.Assets)
local Theme = require(Plugin.App.Theme)
local bindingUtil = require(Plugin.App.bindingUtil)
local SlicedImage = require(script.Parent.SlicedImage)
local ScrollingFrame = require(script.Parent.ScrollingFrame)
local e = Roact.createElement
local Dropdown = Roact.Component:extend("Dropdown")
function Dropdown:init()
self.openMotor = Flipper.SingleMotor.new(0)
self.openBinding = bindingUtil.fromMotor(self.openMotor)
self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0))
self:setState({
open = false,
})
end
function Dropdown:didUpdate()
self.openMotor:setGoal(
Flipper.Spring.new(self.state.open and 1 or 0, {
frequency = 6,
dampingRatio = 1.1,
})
)
end
function Dropdown:render()
return Theme.with(function(theme)
theme = theme.Dropdown
local optionButtons = {}
local width = -1
for i, option in self.props.options do
local text = tostring(option or "")
local textSize = TextService:GetTextSize(
text, 15, Enum.Font.GothamMedium,
Vector2.new(math.huge, 20)
)
if textSize.X > width then
width = textSize.X
end
optionButtons[text] = e("TextButton", {
Text = text,
LayoutOrder = i,
Size = UDim2.new(1, 0, 0, 24),
BackgroundColor3 = theme.BackgroundColor,
TextTransparency = self.props.transparency,
BackgroundTransparency = self.props.transparency,
BorderSizePixel = 0,
TextColor3 = theme.TextColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextSize = 15,
Font = Enum.Font.GothamMedium,
[Roact.Event.Activated] = function()
self:setState({
open = false,
})
self.props.onClick(option)
end,
}, {
Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 6),
}),
})
end
return e("ImageButton", {
Size = UDim2.new(0, width+50, 0, 28),
Position = self.props.position,
AnchorPoint = self.props.anchorPoint,
LayoutOrder = self.props.layoutOrder,
ZIndex = self.props.zIndex,
BackgroundTransparency = 1,
[Roact.Event.Activated] = function()
self:setState({
open = not self.state.open,
})
end,
}, {
Border = e(SlicedImage, {
slice = Assets.Slices.RoundedBorder,
color = theme.BorderColor,
transparency = self.props.transparency,
size = UDim2.new(1, 0, 1, 0),
}, {
DropArrow = e("ImageLabel", {
Image = Assets.Images.Dropdown.Arrow,
ImageColor3 = self.openBinding:map(function(a)
return theme.Closed.IconColor:Lerp(theme.Open.IconColor, a)
end),
ImageTransparency = self.props.transparency,
Size = UDim2.new(0, 18, 0, 18),
Position = UDim2.new(1, -6, 0.5, 0),
AnchorPoint = Vector2.new(1, 0.5),
Rotation = self.openBinding:map(function(a)
return a * 180
end),
BackgroundTransparency = 1,
}),
Active = e("TextLabel", {
Size = UDim2.new(1, -30, 1, 0),
Position = UDim2.new(0, 6, 0, 0),
BackgroundTransparency = 1,
Text = self.props.active,
Font = Enum.Font.GothamMedium,
TextSize = 15,
TextColor3 = theme.TextColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = self.props.transparency,
}),
}),
Options = if self.state.open then e(SlicedImage, {
slice = Assets.Slices.RoundedBackground,
color = theme.BackgroundColor,
position = UDim2.new(1, 0, 1, 3),
size = self.openBinding:map(function(a)
return UDim2.new(1, 0, a*math.min(3, #self.props.options), 0)
end),
anchorPoint = Vector2.new(1, 0),
}, {
Border = e(SlicedImage, {
slice = Assets.Slices.RoundedBorder,
color = theme.BorderColor,
transparency = self.props.transparency,
size = UDim2.new(1, 0, 1, 0),
}),
ScrollingFrame = e(ScrollingFrame, {
size = UDim2.new(1, -4, 1, -4),
position = UDim2.new(0, 2, 0, 2),
transparency = self.props.transparency,
contentSize = self.contentSize,
}, {
Layout = e("UIListLayout", {
VerticalAlignment = Enum.VerticalAlignment.Top,
FillDirection = Enum.FillDirection.Vertical,
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 0),
[Roact.Change.AbsoluteContentSize] = function(object)
self.setContentSize(object.AbsoluteContentSize)
end,
}),
Roact.createFragment(optionButtons),
}),
}) else nil,
})
end)
end
return Dropdown

View File

@@ -1,7 +1,8 @@
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Rojo.Roact) local Roact = require(Packages.Roact)
local Theme = require(Plugin.App.Theme) local Theme = require(Plugin.App.Theme)
local Assets = require(Plugin.Assets) local Assets = require(Plugin.Assets)
@@ -52,4 +53,4 @@ local function Header(props)
end) end)
end end
return Header return Header

View File

@@ -1,8 +1,9 @@
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Rojo.Roact) local Roact = require(Packages.Roact)
local Flipper = require(Rojo.Flipper) local Flipper = require(Packages.Flipper)
local Assets = require(Plugin.Assets) local Assets = require(Plugin.Assets)
local bindingUtil = require(Plugin.App.bindingUtil) local bindingUtil = require(Plugin.App.bindingUtil)
@@ -29,6 +30,7 @@ function IconButton:render()
Position = self.props.position, Position = self.props.position,
AnchorPoint = self.props.anchorPoint, AnchorPoint = self.props.anchorPoint,
Visible = self.props.visible,
LayoutOrder = self.props.layoutOrder, LayoutOrder = self.props.layoutOrder,
ZIndex = self.props.zIndex, ZIndex = self.props.zIndex,
BackgroundTransparency = 1, BackgroundTransparency = 1,
@@ -73,7 +75,9 @@ function IconButton:render()
BackgroundTransparency = 1, BackgroundTransparency = 1,
}), }),
Children = Roact.createFragment(self.props[Roact.Children]),
}) })
end end
return IconButton return IconButton

View File

@@ -0,0 +1,181 @@
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Theme = require(Plugin.App.Theme)
local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame)
local DisplayValue = require(script.Parent.DisplayValue)
local e = Roact.createElement
local ChangeList = Roact.Component:extend("ChangeList")
function ChangeList:init()
self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0))
end
function ChangeList:render()
return Theme.with(function(theme)
local props = self.props
local changes = props.changes
-- Color alternating rows for readability
local rowTransparency = props.transparency:map(function(t)
return 0.93 + (0.07 * t)
end)
local columnVisibility = props.columnVisibility
local rows = {}
local pad = {
PaddingLeft = UDim.new(0, 5),
PaddingRight = UDim.new(0, 5),
}
local headers = e("Frame", {
Size = UDim2.new(1, 0, 0, 30),
BackgroundTransparency = rowTransparency,
BackgroundColor3 = theme.Diff.Row,
LayoutOrder = 0,
}, {
Padding = e("UIPadding", pad),
Layout = e("UIListLayout", {
FillDirection = Enum.FillDirection.Horizontal,
SortOrder = Enum.SortOrder.LayoutOrder,
HorizontalAlignment = Enum.HorizontalAlignment.Left,
VerticalAlignment = Enum.VerticalAlignment.Center,
}),
A = e("TextLabel", {
Visible = columnVisibility[1],
Text = tostring(changes[1][1]),
BackgroundTransparency = 1,
Font = Enum.Font.GothamBold,
TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd,
Size = UDim2.new(0.3, 0, 1, 0),
LayoutOrder = 1,
}),
B = e("TextLabel", {
Visible = columnVisibility[2],
Text = tostring(changes[1][2]),
BackgroundTransparency = 1,
Font = Enum.Font.GothamBold,
TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd,
Size = UDim2.new(0.35, 0, 1, 0),
LayoutOrder = 2,
}),
C = e("TextLabel", {
Visible = columnVisibility[3],
Text = tostring(changes[1][3]),
BackgroundTransparency = 1,
Font = Enum.Font.GothamBold,
TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd,
Size = UDim2.new(0.35, 0, 1, 0),
LayoutOrder = 3,
}),
})
for row, values in changes do
if row == 1 then
continue -- Skip headers, already handled above
end
rows[row] = e("Frame", {
Size = UDim2.new(1, 0, 0, 30),
BackgroundTransparency = row % 2 ~= 0 and rowTransparency or 1,
BackgroundColor3 = theme.Diff.Row,
BorderSizePixel = 0,
LayoutOrder = row,
}, {
Padding = e("UIPadding", pad),
Layout = e("UIListLayout", {
FillDirection = Enum.FillDirection.Horizontal,
SortOrder = Enum.SortOrder.LayoutOrder,
HorizontalAlignment = Enum.HorizontalAlignment.Left,
VerticalAlignment = Enum.VerticalAlignment.Center,
}),
A = e("TextLabel", {
Visible = columnVisibility[1],
Text = tostring(values[1]),
BackgroundTransparency = 1,
Font = Enum.Font.GothamMedium,
TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd,
Size = UDim2.new(0.3, 0, 1, 0),
LayoutOrder = 1,
}),
B = e(
"Frame",
{
Visible = columnVisibility[2],
BackgroundTransparency = 1,
Size = UDim2.new(0.35, 0, 1, 0),
LayoutOrder = 2,
},
e(DisplayValue, {
value = values[2],
transparency = props.transparency,
})
),
C = e(
"Frame",
{
Visible = columnVisibility[3],
BackgroundTransparency = 1,
Size = UDim2.new(0.35, 0, 1, 0),
LayoutOrder = 3,
},
e(DisplayValue, {
value = values[3],
transparency = props.transparency,
})
),
})
end
table.insert(
rows,
e("UIListLayout", {
FillDirection = Enum.FillDirection.Vertical,
SortOrder = Enum.SortOrder.LayoutOrder,
HorizontalAlignment = Enum.HorizontalAlignment.Right,
VerticalAlignment = Enum.VerticalAlignment.Top,
[Roact.Change.AbsoluteContentSize] = function(object)
self.setContentSize(object.AbsoluteContentSize)
end,
})
)
return e("Frame", {
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
}, {
Headers = headers,
Values = e(ScrollingFrame, {
size = UDim2.new(1, 0, 1, -30),
position = UDim2.new(0, 0, 0, 30),
contentSize = self.contentSize,
transparency = props.transparency,
}, rows),
})
end)
end
return ChangeList

View File

@@ -0,0 +1,107 @@
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Theme = require(Plugin.App.Theme)
local e = Roact.createElement
local function DisplayValue(props)
return Theme.with(function(theme)
local t = typeof(props.value)
if t == "Color3" then
-- Colors get a blot that shows the color
return Roact.createFragment({
Blot = e("Frame", {
BackgroundTransparency = props.transparency,
BackgroundColor3 = props.value,
Size = UDim2.new(0, 20, 0, 20),
Position = UDim2.new(0, 0, 0.5, 0),
AnchorPoint = Vector2.new(0, 0.5),
}, {
Corner = e("UICorner", {
CornerRadius = UDim.new(0, 4),
}),
Stroke = e("UIStroke", {
Color = theme.BorderedContainer.BorderColor,
Transparency = props.transparency,
}),
}),
Label = e("TextLabel", {
Text = string.format("%d,%d,%d", props.value.R * 255, props.value.G * 255, props.value.B * 255),
BackgroundTransparency = 1,
Font = Enum.Font.GothamMedium,
TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd,
Size = UDim2.new(1, -25, 1, 0),
Position = UDim2.new(0, 25, 0, 0),
}),
})
elseif t == "table" then
-- Showing a memory address for tables is useless, so we want to show the best we can
local textRepresentation = nil
local meta = getmetatable(props.value)
if meta and meta.__tostring then
-- If the table has a tostring metamethod, use that
textRepresentation = tostring(props.value)
elseif next(props.value) == nil then
-- If it's empty, show empty braces
textRepresentation = "{}"
else
-- If it has children, list them out
local out, i = {}, 0
for k, v in pairs(props.value) do
i += 1
-- Wrap strings in quotes
if type(k) == "string" then
k = "\"" .. k .. "\""
end
if type(v) == "string" then
v = "\"" .. v .. "\""
end
out[i] = string.format("[%s] = %s", tostring(k), tostring(v))
end
textRepresentation = "{ " .. table.concat(out, ", ") .. " }"
end
return e("TextLabel", {
Text = textRepresentation,
BackgroundTransparency = 1,
Font = Enum.Font.GothamMedium,
TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd,
Size = UDim2.new(1, 0, 1, 0),
})
end
-- TODO: Maybe add visualizations to other datatypes?
-- Or special text handling tostring for some?
-- Will add as needed, let's see what cases arise.
return e("TextLabel", {
Text = string.gsub(tostring(props.value), "%s", " "),
BackgroundTransparency = 1,
Font = Enum.Font.GothamMedium,
TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd,
Size = UDim2.new(1, 0, 1, 0),
})
end)
end
return DisplayValue

View File

@@ -0,0 +1,180 @@
local StudioService = game:GetService("StudioService")
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Flipper = require(Packages.Flipper)
local Assets = require(Plugin.Assets)
local Theme = require(Plugin.App.Theme)
local bindingUtil = require(Plugin.App.bindingUtil)
local e = Roact.createElement
local ChangeList = require(script.Parent.ChangeList)
local Expansion = Roact.Component:extend("Expansion")
function Expansion:render()
local props = self.props
if not props.rendered then
return nil
end
return e("Frame", {
BackgroundTransparency = 1,
Size = UDim2.new(1, -props.indent, 1, -30),
Position = UDim2.new(0, props.indent, 0, 30),
}, {
ChangeList = e(ChangeList, {
changes = props.changeList,
transparency = props.transparency,
columnVisibility = props.columnVisibility,
}),
})
end
local DomLabel = Roact.Component:extend("DomLabel")
function DomLabel:init()
self.maxElementHeight = 0
if self.props.changeList then
self.maxElementHeight = math.clamp(#self.props.changeList * 30, 30, 30 * 6)
end
local initHeight = self.props.elementHeight:getValue()
self.expanded = initHeight > 30
self.motor = Flipper.SingleMotor.new(initHeight)
self.binding = bindingUtil.fromMotor(self.motor)
self:setState({
renderExpansion = self.expanded,
})
self.motor:onStep(function(value)
local renderExpansion = value > 30
self.props.setElementHeight(value)
if self.props.updateEvent then
self.props.updateEvent:Fire()
end
self:setState(function(state)
if state.renderExpansion == renderExpansion then
return nil
end
return {
renderExpansion = renderExpansion,
}
end)
end)
end
function DomLabel:render()
local props = self.props
return Theme.with(function(theme)
local iconProps = StudioService:GetClassIcon(props.className)
local indent = (props.depth or 0) * 20 + 25
-- Line guides help indent depth remain readable
local lineGuides = {}
for i = 1, props.depth or 0 do
table.insert(
lineGuides,
e("Frame", {
Name = "Line_" .. i,
Size = UDim2.new(0, 2, 1, 2),
Position = UDim2.new(0, (20 * i) + 15, 0, -1),
BorderSizePixel = 0,
BackgroundTransparency = props.transparency,
BackgroundColor3 = theme.BorderedContainer.BorderColor,
})
)
end
return e("Frame", {
Name = "Change",
ClipsDescendants = true,
BackgroundColor3 = if props.patchType then theme.Diff[props.patchType] else nil,
BorderSizePixel = 0,
BackgroundTransparency = props.patchType and props.transparency or 1,
Size = self.binding:map(function(expand)
return UDim2.new(1, 0, 0, expand)
end),
}, {
Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 10),
PaddingRight = UDim.new(0, 10),
}),
ExpandButton = if props.changeList
then e("TextButton", {
BackgroundTransparency = 1,
Text = "",
Size = UDim2.new(1, 0, 1, 0),
[Roact.Event.Activated] = function()
self.expanded = not self.expanded
self.motor:setGoal(Flipper.Spring.new((self.expanded and self.maxElementHeight or 0) + 30, {
frequency = 5,
dampingRatio = 1,
}))
end,
})
else nil,
Expansion = if props.changeList
then e(Expansion, {
rendered = self.state.renderExpansion,
indent = indent,
transparency = props.transparency,
changeList = props.changeList,
columnVisibility = props.columnVisibility,
})
else nil,
DiffIcon = if props.patchType
then e("ImageLabel", {
Image = Assets.Images.Diff[props.patchType],
ImageColor3 = theme.AddressEntry.PlaceholderColor,
ImageTransparency = props.transparency,
BackgroundTransparency = 1,
Size = UDim2.new(0, 20, 0, 20),
Position = UDim2.new(0, 0, 0, 15),
AnchorPoint = Vector2.new(0, 0.5),
})
else nil,
ClassIcon = e("ImageLabel", {
Image = iconProps.Image,
ImageTransparency = props.transparency,
ImageRectOffset = iconProps.ImageRectOffset,
ImageRectSize = iconProps.ImageRectSize,
BackgroundTransparency = 1,
Size = UDim2.new(0, 20, 0, 20),
Position = UDim2.new(0, indent, 0, 15),
AnchorPoint = Vector2.new(0, 0.5),
}),
InstanceName = e("TextLabel", {
Text = props.name .. (props.hint and string.format(
' <font color="#%s">%s</font>',
theme.AddressEntry.PlaceholderColor:ToHex(),
props.hint
) or ""),
RichText = true,
BackgroundTransparency = 1,
Font = Enum.Font.GothamMedium,
TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = props.transparency,
TextTruncate = Enum.TextTruncate.AtEnd,
Size = UDim2.new(1, -indent - 50, 0, 30),
Position = UDim2.new(0, indent + 30, 0, 0),
}),
LineGuides = e("Folder", nil, lineGuides),
})
end)
end
return DomLabel

View File

@@ -0,0 +1,402 @@
local HttpService = game:GetService("HttpService")
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Log = require(Packages.Log)
local PatchSet = require(Plugin.PatchSet)
local decodeValue = require(Plugin.Reconciler.decodeValue)
local getProperty = require(Plugin.Reconciler.getProperty)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local VirtualScroller = require(Plugin.App.Components.VirtualScroller)
local e = Roact.createElement
local function alphabeticalNext(t, state)
-- Equivalent of the next function, but returns the keys in the alphabetic
-- order of node names. We use a temporary ordered key table that is stored in the
-- table being iterated.
local key = nil
if state == nil then
-- First iteration, generate the index
local orderedIndex, i = table.create(5), 0
for k in t do
i += 1
orderedIndex[i] = k
end
table.sort(orderedIndex, function(a, b)
local nodeA, nodeB = t[a], t[b]
return (nodeA.name or "") < (nodeB.name or "")
end)
t.__orderedIndex = orderedIndex
key = orderedIndex[1]
else
-- Fetch the next value
for i, orderedState in t.__orderedIndex do
if orderedState == state then
key = t.__orderedIndex[i + 1]
break
end
end
end
if key then
return key, t[key]
end
-- No more value to return, cleanup
t.__orderedIndex = nil
return
end
local function alphabeticalPairs(t)
-- Equivalent of the pairs() iterator, but sorted
return alphabeticalNext, t, nil
end
local function Tree()
local tree = {
idToNode = {},
ROOT = {
className = "DataModel",
name = "ROOT",
children = {},
},
}
-- Add ROOT to idToNode or it won't be found by getNode since that searches *within* ROOT
tree.idToNode["ROOT"] = tree.ROOT
function tree:getNode(id, target)
if self.idToNode[id] then
return self.idToNode[id]
end
for nodeId, node in target or tree.ROOT.children do
if nodeId == id then
self.idToNode[id] = node
return node
end
local descendant = self:getNode(id, node.children)
if descendant then
return descendant
end
end
return nil
end
function tree:addNode(parent, props)
parent = parent or "ROOT"
local node = self:getNode(props.id)
if node then
for k, v in props do
node[k] = v
end
return node
end
node = table.clone(props)
node.children = {}
local parentNode = self:getNode(parent)
if not parentNode then
Log.warn("Failed to create node since parent doesnt exist: {}, {}", parent, props)
return
end
parentNode.children[node.id] = node
self.idToNode[node.id] = node
return node
end
function tree:buildAncestryNodes(ancestry, patch, instanceMap)
-- Build nodes for ancestry by going up the tree
local previousId = "ROOT"
for _, ancestorId in ancestry do
local value = instanceMap.fromIds[ancestorId] or patch.added[ancestorId]
if not value then
Log.warn("Failed to find ancestor object for " .. ancestorId)
continue
end
self:addNode(previousId, {
id = ancestorId,
className = value.ClassName,
name = value.Name,
})
previousId = ancestorId
end
end
return tree
end
local DomLabel = require(script.DomLabel)
local PatchVisualizer = Roact.Component:extend("PatchVisualizer")
function PatchVisualizer:init()
self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0))
self.updateEvent = Instance.new("BindableEvent")
end
function PatchVisualizer:willUnmount()
self.updateEvent:Destroy()
end
function PatchVisualizer:shouldUpdate(nextProps)
local currentPatch, nextPatch = self.props.patch, nextProps.patch
return not PatchSet.isEqual(currentPatch, nextPatch)
end
function PatchVisualizer:buildTree(patch, instanceMap)
local tree = Tree()
for _, change in patch.updated do
local instance = instanceMap.fromIds[change.id]
if not instance then
continue
end
-- Gather ancestors from existing DOM
local ancestry = {}
local parentObject = instance.Parent
local parentId = instanceMap.fromInstances[parentObject]
while parentObject do
table.insert(ancestry, 1, parentId)
parentObject = parentObject.Parent
parentId = instanceMap.fromInstances[parentObject]
end
tree:buildAncestryNodes(ancestry, patch, instanceMap)
-- Gather detail text
local changeList, hint = nil, nil
if next(change.changedProperties) or change.changedName then
changeList = {}
local hintBuffer, i = {}, 0
local function addProp(prop: string, current: any?, incoming: any?)
i += 1
hintBuffer[i] = prop
changeList[i] = { prop, current, incoming }
end
-- Gather the changes
if change.changedName then
addProp("Name", instance.Name, change.changedName)
end
for prop, incoming in change.changedProperties do
local incomingSuccess, incomingValue = decodeValue(incoming, instanceMap)
local currentSuccess, currentValue = getProperty(instance, prop)
addProp(
prop,
if currentSuccess then currentValue else "[Error]",
if incomingSuccess then incomingValue else next(incoming)
)
end
-- Finalize detail values
-- Trim hint to top 3
table.sort(hintBuffer)
if #hintBuffer > 3 then
hintBuffer = {
hintBuffer[1],
hintBuffer[2],
hintBuffer[3],
i - 3 .. " more",
}
end
hint = table.concat(hintBuffer, ", ")
-- Sort changes and add header
table.sort(changeList, function(a, b)
return a[1] < b[1]
end)
table.insert(changeList, 1, { "Property", "Current", "Incoming" })
end
-- Add this node to tree
tree:addNode(instanceMap.fromInstances[instance.Parent], {
id = change.id,
patchType = "Edit",
className = instance.ClassName,
name = instance.Name,
hint = hint,
changeList = changeList,
})
end
for _, instance in patch.removed do
-- Gather ancestors from existing DOM
-- (note that they may have no ID if they're being removed as unknown)
local ancestry = {}
local parentObject = instance.Parent
local parentId = instanceMap.fromInstances[parentObject] or HttpService:GenerateGUID(false)
while parentObject do
instanceMap:insert(parentId, parentObject)
table.insert(ancestry, 1, parentId)
parentObject = parentObject.Parent
parentId = instanceMap.fromInstances[parentObject] or HttpService:GenerateGUID(false)
end
tree:buildAncestryNodes(ancestry, patch, instanceMap)
-- Add this node to tree
local nodeId = instanceMap.fromInstances[instance] or HttpService:GenerateGUID(false)
instanceMap:insert(nodeId, instance)
tree:addNode(instanceMap.fromInstances[instance.Parent], {
id = nodeId,
patchType = "Remove",
className = instance.ClassName,
name = instance.Name,
})
end
for _, change in patch.added do
-- Gather ancestors from existing DOM or future additions
local ancestry = {}
local parentId = change.Parent
local parentData = patch.added[parentId]
local parentObject = instanceMap.fromIds[parentId]
while parentId do
table.insert(ancestry, 1, parentId)
parentId = nil
if parentData then
parentId = parentData.Parent
parentData = patch.added[parentId]
parentObject = instanceMap.fromIds[parentId]
elseif parentObject then
parentObject = parentObject.Parent
parentId = instanceMap.fromInstances[parentObject]
parentData = patch.added[parentId]
end
end
tree:buildAncestryNodes(ancestry, patch, instanceMap)
-- Gather detail text
local changeList, hint = nil, nil
if next(change.Properties) then
changeList = {}
local hintBuffer, i = {}, 0
for prop, incoming in change.Properties do
i += 1
hintBuffer[i] = prop
local success, incomingValue = decodeValue(incoming, instanceMap)
if success then
table.insert(changeList, { prop, "N/A", incomingValue })
else
table.insert(changeList, { prop, "N/A", next(incoming) })
end
end
-- Finalize detail values
-- Trim hint to top 3
table.sort(hintBuffer)
if #hintBuffer > 3 then
hintBuffer = {
hintBuffer[1],
hintBuffer[2],
hintBuffer[3],
i - 3 .. " more",
}
end
hint = table.concat(hintBuffer, ", ")
-- Sort changes and add header
table.sort(changeList, function(a, b)
return a[1] < b[1]
end)
table.insert(changeList, 1, { "Property", "Current", "Incoming" })
end
-- Add this node to tree
tree:addNode(change.Parent, {
id = change.Id,
patchType = "Add",
className = change.ClassName,
name = change.Name,
hint = hint,
changeList = changeList,
})
end
return tree
end
function PatchVisualizer:render()
local patch = self.props.patch
local instanceMap = self.props.instanceMap
local tree = self:buildTree(patch, instanceMap)
-- Recusively draw tree
local scrollElements, elementHeights = {}, {}
local function drawNode(node, depth)
local elementHeight, setElementHeight = Roact.createBinding(30)
table.insert(elementHeights, elementHeight)
table.insert(
scrollElements,
e(DomLabel, {
columnVisibility = self.props.columnVisibility,
updateEvent = self.updateEvent,
elementHeight = elementHeight,
setElementHeight = setElementHeight,
patchType = node.patchType,
className = node.className,
name = node.name,
hint = node.hint,
changeList = node.changeList,
depth = depth,
transparency = self.props.transparency,
})
)
for _, childNode in alphabeticalPairs(node.children) do
drawNode(childNode, depth + 1)
end
end
for _, node in alphabeticalPairs(tree.ROOT.children) do
drawNode(node, 0)
end
return e(BorderedContainer, {
transparency = self.props.transparency,
size = self.props.size,
position = self.props.position,
layoutOrder = self.props.layoutOrder,
}, {
VirtualScroller = e(VirtualScroller, {
size = UDim2.new(1, 0, 1, 0),
transparency = self.props.transparency,
count = #scrollElements,
updateEvent = self.updateEvent.Event,
render = function(i)
return scrollElements[i]
end,
getHeightBinding = function(i)
return elementHeights[i]
end,
}),
})
end
return PatchVisualizer

View File

@@ -1,7 +1,8 @@
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Rojo.Roact) local Roact = require(Packages.Roact)
local Assets = require(Plugin.Assets) local Assets = require(Plugin.Assets)
local Theme = require(Plugin.App.Theme) local Theme = require(Plugin.App.Theme)
@@ -39,4 +40,4 @@ local function ScrollingFrame(props)
end) end)
end end
return ScrollingFrame return ScrollingFrame

View File

@@ -1,6 +1,7 @@
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Packages = Rojo.Packages
local Roact = require(Rojo.Roact) local Roact = require(Packages.Roact)
local e = Roact.createElement local e = Roact.createElement
@@ -26,4 +27,4 @@ local function SlicedImage(props)
}, props[Roact.Children]) }, props[Roact.Children])
end end
return SlicedImage return SlicedImage

View File

@@ -2,8 +2,9 @@ local RunService = game:GetService("RunService")
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Rojo.Roact) local Roact = require(Packages.Roact)
local Theme = require(Plugin.App.Theme) local Theme = require(Plugin.App.Theme)
local Assets = require(Plugin.Assets) local Assets = require(Plugin.Assets)
@@ -63,4 +64,4 @@ function Spinner:willUnmount()
self.stepper:Disconnect() self.stepper:Disconnect()
end end
return Spinner return Spinner

View File

@@ -1,7 +1,8 @@
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Rojo.Roact) local Roact = require(Packages.Roact)
local Dictionary = require(Plugin.Dictionary) local Dictionary = require(Plugin.Dictionary)

View File

@@ -1,7 +1,8 @@
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Packages = Rojo.Packages
local Roact = require(Rojo.Roact) local Roact = require(Packages.Roact)
local StudioPluginContext = Roact.createContext(nil) local StudioPluginContext = Roact.createContext(nil)
return StudioPluginContext return StudioPluginContext

View File

@@ -1,9 +1,11 @@
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Rojo.Roact) local Roact = require(Packages.Roact)
local Dictionary = require(Plugin.Dictionary) local Dictionary = require(Plugin.Dictionary)
local Theme = require(Plugin.App.Theme)
local StudioPluginContext = require(script.Parent.StudioPluginContext) local StudioPluginContext = require(script.Parent.StudioPluginContext)
@@ -28,8 +30,10 @@ function StudioPluginGui:init()
self.props.initDockState, self.props.initDockState,
self.props.active, self.props.active,
self.props.overridePreviousState, self.props.overridePreviousState,
floatingSize.X, floatingSize.Y, floatingSize.X,
minimumSize.X, minimumSize.Y floatingSize.Y,
minimumSize.X,
minimumSize.Y
) )
local pluginGui = self.props.plugin:CreateDockWidgetPluginGui(self.props.id, dockWidgetPluginGuiInfo) local pluginGui = self.props.plugin:CreateDockWidgetPluginGui(self.props.id, dockWidgetPluginGuiInfo)
@@ -56,7 +60,16 @@ end
function StudioPluginGui:render() function StudioPluginGui:render()
return e(Roact.Portal, { return e(Roact.Portal, {
target = self.pluginGui, target = self.pluginGui,
}, self.props[Roact.Children]) }, {
Background = Theme.with(function(theme)
return e("Frame", {
Size = UDim2.new(1, 0, 1, 0),
BackgroundColor3 = theme.BackgroundColor,
ZIndex = 0,
BorderSizePixel = 0,
}, self.props[Roact.Children])
end),
})
end end
function StudioPluginGui:didUpdate(lastProps) function StudioPluginGui:didUpdate(lastProps)
@@ -74,11 +87,14 @@ end
local function StudioPluginGuiWrapper(props) local function StudioPluginGuiWrapper(props)
return e(StudioPluginContext.Consumer, { return e(StudioPluginContext.Consumer, {
render = function(plugin) render = function(plugin)
return e(StudioPluginGui, Dictionary.merge(props, { return e(
plugin = plugin, StudioPluginGui,
})) Dictionary.merge(props, {
plugin = plugin,
})
)
end, end,
}) })
end end
return StudioPluginGuiWrapper return StudioPluginGuiWrapper

View File

@@ -1,7 +1,8 @@
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Rojo.Roact) local Roact = require(Packages.Roact)
local Dictionary = require(Plugin.Dictionary) local Dictionary = require(Plugin.Dictionary)

View File

@@ -1,7 +1,8 @@
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Rojo.Roact) local Roact = require(Packages.Roact)
local Dictionary = require(Plugin.Dictionary) local Dictionary = require(Plugin.Dictionary)
@@ -42,4 +43,4 @@ local function StudioToolbarWrapper(props)
}) })
end end
return StudioToolbarWrapper return StudioToolbarWrapper

View File

@@ -1,7 +1,8 @@
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Packages = Rojo.Packages
local Roact = require(Rojo.Roact) local Roact = require(Packages.Roact)
local StudioToolbarContext = Roact.createContext(nil) local StudioToolbarContext = Roact.createContext(nil)
return StudioToolbarContext return StudioToolbarContext

View File

@@ -2,9 +2,10 @@ local TextService = game:GetService("TextService")
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Rojo.Roact) local Roact = require(Packages.Roact)
local Flipper = require(Rojo.Flipper) local Flipper = require(Packages.Flipper)
local Theme = require(Plugin.App.Theme) local Theme = require(Plugin.App.Theme)
local Assets = require(Plugin.Assets) local Assets = require(Plugin.Assets)
@@ -130,8 +131,10 @@ function TextButton:render()
zIndex = -2, zIndex = -2,
}), }),
Children = Roact.createFragment(self.props[Roact.Children]),
}) })
end) end)
end end
return TextButton return TextButton

View File

@@ -0,0 +1,226 @@
local TextService = game:GetService("TextService")
local HttpService = game:GetService("HttpService")
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Theme = require(Plugin.App.Theme)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local e = Roact.createElement
local DELAY = 0.75 -- How long to hover before a popup is shown (seconds)
local TEXT_PADDING = Vector2.new(8 * 2, 6 * 2) -- Padding for the popup text containers
local TAIL_SIZE = 16 -- Size of the triangle tail piece
local X_OFFSET = 30 -- How far right (from left) the tail will be (assuming enough space)
local Y_OVERLAP = 10 -- Let the triangle tail piece overlap the target a bit to help "connect" it
local TooltipContext = Roact.createContext({})
local function Popup(props)
local textSize = TextService:GetTextSize(
props.Text, 16, Enum.Font.GothamMedium, Vector2.new(math.min(props.parentSize.X, 160), math.huge)
) + TEXT_PADDING + (Vector2.one * 2)
local trigger = props.Trigger:getValue()
local spaceBelow = props.parentSize.Y - (trigger.AbsolutePosition.Y + trigger.AbsoluteSize.Y - Y_OVERLAP + TAIL_SIZE)
local spaceAbove = trigger.AbsolutePosition.Y + Y_OVERLAP - TAIL_SIZE
-- If there's not enough space below, and there's more space above, then show the tooltip above the trigger
local displayAbove = spaceBelow < textSize.Y and spaceAbove > spaceBelow
local X = math.clamp(props.Position.X - X_OFFSET, 0, props.parentSize.X - textSize.X)
local Y = 0
if displayAbove then
Y = math.max(trigger.AbsolutePosition.Y - TAIL_SIZE - textSize.Y + Y_OVERLAP, 0)
else
Y = math.min(trigger.AbsolutePosition.Y + trigger.AbsoluteSize.Y + TAIL_SIZE - Y_OVERLAP, props.parentSize.Y - textSize.Y)
end
return Theme.with(function(theme)
return e(BorderedContainer, {
position = UDim2.fromOffset(X, Y),
size = UDim2.fromOffset(textSize.X, textSize.Y),
transparency = props.transparency,
}, {
Label = e("TextLabel", {
BackgroundTransparency = 1,
Position = UDim2.fromScale(0.5, 0.5),
Size = UDim2.new(1, -TEXT_PADDING.X, 1, -TEXT_PADDING.Y),
AnchorPoint = Vector2.new(0.5, 0.5),
Text = props.Text,
TextSize = 16,
Font = Enum.Font.GothamMedium,
TextWrapped = true,
TextXAlignment = Enum.TextXAlignment.Left,
TextColor3 = theme.Button.Bordered.Enabled.TextColor,
TextTransparency = props.transparency,
}),
Tail = e("ImageLabel", {
ZIndex = 100,
Position =
if displayAbove then
UDim2.new(
0, math.clamp(props.Position.X - X, 6, textSize.X-6),
1, -1
)
else
UDim2.new(
0, math.clamp(props.Position.X - X, 6, textSize.X-6),
0, -TAIL_SIZE+1
),
Size = UDim2.fromOffset(TAIL_SIZE, TAIL_SIZE),
AnchorPoint = Vector2.new(0.5, 0),
Rotation = if displayAbove then 180 else 0,
BackgroundTransparency = 1,
Image = "rbxassetid://10983945016",
ImageColor3 = theme.BorderedContainer.BackgroundColor,
ImageTransparency = props.transparency,
}, {
Border = e("ImageLabel", {
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
Image = "rbxassetid://10983946430",
ImageColor3 = theme.BorderedContainer.BorderColor,
ImageTransparency = props.transparency,
}),
})
})
end)
end
local Provider = Roact.Component:extend("TooltipManager")
function Provider:init()
self:setState({
tips = {},
addTip = function(id: string, data: { Text: string, Position: Vector2, Trigger: any })
self:setState(function(state)
state.tips[id] = data
return state
end)
end,
removeTip = function(id: string)
self:setState(function(state)
state.tips[id] = nil
return state
end)
end,
})
end
function Provider:render()
return Roact.createElement(TooltipContext.Provider, {
value = self.state,
}, self.props[Roact.Children])
end
local Container = Roact.Component:extend("TooltipContainer")
function Container:init()
self:setState({
size = Vector2.new(200, 100),
})
end
function Container:render()
return Roact.createElement(TooltipContext.Consumer, {
render = function(context)
local tips = context.tips
local popups = {}
for key, value in tips do
popups[key] = e(Popup, {
Text = value.Text or "",
Position = value.Position or Vector2.zero,
Trigger = value.Trigger,
parentSize = self.state.size,
})
end
return e("Frame", {
[Roact.Change.AbsoluteSize] = function(rbx)
self:setState({
size = rbx.AbsoluteSize,
})
end,
ZIndex = 100,
BackgroundTransparency = 1,
Size = UDim2.fromScale(1, 1),
}, popups)
end,
})
end
local Trigger = Roact.Component:extend("TooltipTrigger")
function Trigger:init()
self.id = HttpService:GenerateGUID(false)
self.ref = Roact.createRef()
self.mousePos = Vector2.zero
self.destroy = function()
self.props.context.removeTip(self.id)
end
end
function Trigger:willUnmount()
if self.showDelayThread then
task.cancel(self.showDelayThread)
end
if self.destroy then
self.destroy()
end
end
function Trigger:render()
return e("Frame", {
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
ZIndex = self.props.zIndex or 100,
[Roact.Ref] = self.ref,
[Roact.Event.MouseMoved] = function(_rbx, x, y)
self.mousePos = Vector2.new(x, y)
end,
[Roact.Event.MouseEnter] = function()
self.showDelayThread = task.delay(DELAY, function()
self.props.context.addTip(self.id, {
Text = self.props.text,
Position = self.mousePos,
Trigger = self.ref,
})
end)
end,
[Roact.Event.MouseLeave] = function()
if self.showDelayThread then
task.cancel(self.showDelayThread)
end
self.props.context.removeTip(self.id)
end,
})
end
local function TriggerConsumer(props)
return Roact.createElement(TooltipContext.Consumer, {
render = function(context)
local innerProps = table.clone(props)
innerProps.context = context
return e(Trigger, innerProps)
end,
})
end
return {
Provider = Provider,
Container = Container,
Trigger = TriggerConsumer,
}

View File

@@ -1,8 +1,9 @@
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Rojo.Roact) local Roact = require(Packages.Roact)
local Flipper = require(Rojo.Flipper) local Flipper = require(Packages.Flipper)
local Assets = require(Plugin.Assets) local Assets = require(Plugin.Assets)
local bindingUtil = require(Plugin.App.bindingUtil) local bindingUtil = require(Plugin.App.bindingUtil)
@@ -142,4 +143,4 @@ function TouchRipple:render()
}) })
end end
return TouchRipple return TouchRipple

View File

@@ -0,0 +1,156 @@
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Assets = require(Plugin.Assets)
local Theme = require(Plugin.App.Theme)
local bindingUtil = require(Plugin.App.bindingUtil)
local e = Roact.createElement
local VirtualScroller = Roact.Component:extend("VirtualScroller")
function VirtualScroller:init()
self.scrollFrameRef = Roact.createRef()
self:setState({
WindowSize = Vector2.new(),
CanvasPosition = Vector2.new(),
})
self.totalCanvas, self.setTotalCanvas = Roact.createBinding(0)
self.padding, self.setPadding = Roact.createBinding(0)
self:refresh()
if self.props.updateEvent then
self.connection = self.props.updateEvent:Connect(function()
self:refresh()
end)
end
end
function VirtualScroller:didMount()
local rbx = self.scrollFrameRef:getValue()
local windowSizeSignal = rbx:GetPropertyChangedSignal("AbsoluteWindowSize")
self.windowSizeChanged = windowSizeSignal:Connect(function()
self:setState({ WindowSize = rbx.AbsoluteWindowSize })
self:refresh()
end)
local canvasPositionSignal = rbx:GetPropertyChangedSignal("CanvasPosition")
self.canvasPositionChanged = canvasPositionSignal:Connect(function()
if math.abs(rbx.CanvasPosition.Y - self.state.CanvasPosition.Y) > 5 then
self:setState({ CanvasPosition = rbx.CanvasPosition })
self:refresh()
end
end)
self:refresh()
end
function VirtualScroller:willUnmount()
self.windowSizeChanged:Disconnect()
self.canvasPositionChanged:Disconnect()
if self.connection then
self.connection:Disconnect()
self.connection = nil
end
end
function VirtualScroller:refresh()
local props = self.props
local state = self.state
local count = props.count
local windowSize, canvasPosition = state.WindowSize.Y, state.CanvasPosition.Y
local bottom = canvasPosition + windowSize
local minIndex, maxIndex = 1, count
local padding, canvasSize = 0, 0
local pos = 0
for i = 1, count do
local height = props.getHeightBinding(i):getValue()
canvasSize += height
if pos > bottom then
-- Below window
if maxIndex > i then
maxIndex = i
end
end
pos += height
if pos < canvasPosition then
-- Above window
minIndex = i
padding = pos - height
end
end
self.setPadding(padding)
self.setTotalCanvas(canvasSize)
self:setState({
Start = minIndex,
End = maxIndex,
})
end
function VirtualScroller:render()
local props, state = self.props, self.state
local items = {}
for i = state.Start, state.End do
items["Item" .. i] = e("Frame", {
LayoutOrder = i,
Size = props.getHeightBinding(i):map(function(height)
return UDim2.new(1, 0, 0, height)
end),
BackgroundTransparency = 1,
}, props.render(i))
end
return Theme.with(function(theme)
return e("ScrollingFrame", {
Size = props.size,
Position = props.position,
AnchorPoint = props.anchorPoint,
BackgroundTransparency = props.backgroundTransparency or 1,
BackgroundColor3 = props.backgroundColor3,
BorderColor3 = props.borderColor3,
CanvasSize = self.totalCanvas:map(function(s)
return UDim2.fromOffset(0, s)
end),
ScrollBarThickness = 9,
ScrollBarImageColor3 = theme.ScrollBarColor,
ScrollBarImageTransparency = props.transparency:map(function(value)
return bindingUtil.blendAlpha({ 0.65, value })
end),
TopImage = Assets.Images.ScrollBar.Top,
MidImage = Assets.Images.ScrollBar.Middle,
BottomImage = Assets.Images.ScrollBar.Bottom,
ElasticBehavior = Enum.ElasticBehavior.Always,
ScrollingDirection = Enum.ScrollingDirection.Y,
VerticalScrollBarInset = Enum.ScrollBarInset.ScrollBar,
[Roact.Ref] = self.scrollFrameRef,
}, {
Layout = e("UIListLayout", {
Padding = UDim.new(0, 0),
SortOrder = Enum.SortOrder.LayoutOrder,
FillDirection = Enum.FillDirection.Vertical,
}),
Padding = e("UIPadding", {
PaddingTop = self.padding:map(function(p)
return UDim.new(0, p)
end),
}),
Content = Roact.createFragment(items),
})
end)
end
return VirtualScroller

View File

@@ -3,15 +3,15 @@ local StudioService = game:GetService("StudioService")
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Rojo.Roact) local Roact = require(Packages.Roact)
local Flipper = require(Rojo.Flipper) local Flipper = require(Packages.Flipper)
local bindingUtil = require(script.Parent.bindingUtil) local bindingUtil = require(script.Parent.bindingUtil)
local Theme = require(Plugin.App.Theme) local Theme = require(Plugin.App.Theme)
local Assets = require(Plugin.Assets) local Assets = require(Plugin.Assets)
local playSound = require(Plugin.playSound)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer) local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
@@ -53,7 +53,7 @@ function Notification:didMount()
}) })
) )
playSound(Assets.Sounds.Notification) self.props.soundPlayer:play(Assets.Sounds.Notification)
self.timeout = task.spawn(function() self.timeout = task.spawn(function()
local clock = os.clock() local clock = os.clock()
@@ -182,6 +182,7 @@ function Notifications:render()
for index, notif in ipairs(self.props.notifications) do for index, notif in ipairs(self.props.notifications) do
notifs[notif] = e(Notification, { notifs[notif] = e(Notification, {
soundPlayer = self.props.soundPlayer,
text = notif.text, text = notif.text,
timestamp = notif.timestamp, timestamp = notif.timestamp,
timeout = notif.timeout, timeout = notif.timeout,

View File

@@ -1,8 +1,9 @@
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Rojo.Roact) local Roact = require(Packages.Roact)
local Flipper = require(Rojo.Flipper) local Flipper = require(Packages.Flipper)
local Dictionary = require(Plugin.Dictionary) local Dictionary = require(Plugin.Dictionary)
@@ -67,4 +68,4 @@ function Page:didUpdate(lastProps)
end end
end end
return Page return Page

View File

@@ -1,122 +0,0 @@
--[[
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,
showNotifications = true,
}
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

@@ -0,0 +1,156 @@
local TextService = game:GetService("TextService")
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Settings = require(Plugin.Settings)
local Theme = require(Plugin.App.Theme)
local TextButton = require(Plugin.App.Components.TextButton)
local Header = require(Plugin.App.Components.Header)
local StudioPluginGui = require(Plugin.App.Components.Studio.StudioPluginGui)
local Tooltip = require(Plugin.App.Components.Tooltip)
local PatchVisualizer = require(Plugin.App.Components.PatchVisualizer)
local e = Roact.createElement
local ConfirmingPage = Roact.Component:extend("ConfirmingPage")
function ConfirmingPage:init()
self.contentSize, self.setContentSize = Roact.createBinding(0)
self.containerSize, self.setContainerSize = Roact.createBinding(Vector2.new(0, 0))
end
function ConfirmingPage:render()
return Theme.with(function(theme)
local pageContent = Roact.createFragment({
Header = e(Header, {
transparency = self.props.transparency,
layoutOrder = 1,
}),
Title = e("TextLabel", {
Text = string.format(
"Sync changes for project '%s':",
self.props.confirmData.serverInfo.projectName or "UNKNOWN"
),
LayoutOrder = 2,
Font = Enum.Font.Gotham,
LineHeight = 1.2,
TextSize = 14,
TextColor3 = theme.Settings.Setting.DescriptionColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = self.props.transparency,
Size = UDim2.new(1, 0, 0, 20),
BackgroundTransparency = 1,
}),
PatchVisualizer = e(PatchVisualizer, {
size = UDim2.new(1, 0, 1, -150),
transparency = self.props.transparency,
layoutOrder = 3,
columnVisibility = {true, true, true},
patch = self.props.confirmData.patch,
instanceMap = self.props.confirmData.instanceMap,
}),
Buttons = e("Frame", {
Size = UDim2.new(1, 0, 0, 34),
LayoutOrder = 4,
BackgroundTransparency = 1,
}, {
Abort = e(TextButton, {
text = "Abort",
style = "Bordered",
transparency = self.props.transparency,
layoutOrder = 1,
onClick = self.props.onAbort,
}, {
Tip = e(Tooltip.Trigger, {
text = "Stop the connection process"
}),
}),
Reject = if Settings:get("twoWaySync")
then e(TextButton, {
text = "Reject",
style = "Bordered",
transparency = self.props.transparency,
layoutOrder = 2,
onClick = self.props.onReject,
}, {
Tip = e(Tooltip.Trigger, {
text = "Push Studio changes to the Rojo server"
}),
})
else nil,
Accept = e(TextButton, {
text = "Accept",
style = "Solid",
transparency = self.props.transparency,
layoutOrder = 3,
onClick = self.props.onAccept,
}, {
Tip = e(Tooltip.Trigger, {
text = "Pull Rojo server changes to Studio"
}),
}),
Layout = e("UIListLayout", {
HorizontalAlignment = Enum.HorizontalAlignment.Right,
FillDirection = Enum.FillDirection.Horizontal,
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 10),
}),
}),
Layout = e("UIListLayout", {
HorizontalAlignment = Enum.HorizontalAlignment.Center,
VerticalAlignment = Enum.VerticalAlignment.Center,
FillDirection = Enum.FillDirection.Vertical,
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 10),
}),
Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 20),
PaddingRight = UDim.new(0, 20),
}),
})
if self.props.createPopup then
return e(StudioPluginGui, {
id = "Rojo_DiffSync",
title = string.format(
"Confirm sync for project '%s':",
self.props.confirmData.serverInfo.projectName or "UNKNOWN"
),
active = true,
initDockState = Enum.InitialDockState.Float,
initEnabled = true,
overridePreviousState = true,
floatingSize = Vector2.new(500, 350),
minimumSize = Vector2.new(400, 250),
zIndexBehavior = Enum.ZIndexBehavior.Sibling,
onClose = self.props.onAbort,
}, {
Tooltips = e(Tooltip.Container, nil),
Content = e("Frame", {
Size = UDim2.fromScale(1, 1),
BackgroundTransparency = 1,
}, pageContent),
})
end
return pageContent
end)
end
return ConfirmingPage

View File

@@ -1,17 +1,87 @@
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Rojo.Roact) local Roact = require(Packages.Roact)
local Flipper = require(Packages.Flipper)
local bindingUtil = require(Plugin.App.bindingUtil)
local Theme = require(Plugin.App.Theme) local Theme = require(Plugin.App.Theme)
local Assets = require(Plugin.Assets) local Assets = require(Plugin.Assets)
local PatchSet = require(Plugin.PatchSet)
local Header = require(Plugin.App.Components.Header) local Header = require(Plugin.App.Components.Header)
local IconButton = require(Plugin.App.Components.IconButton) local IconButton = require(Plugin.App.Components.IconButton)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer) local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local Tooltip = require(Plugin.App.Components.Tooltip)
local PatchVisualizer = require(Plugin.App.Components.PatchVisualizer)
local e = Roact.createElement local e = Roact.createElement
local AGE_UNITS = { {31556909, "year"}, {2629743, "month"}, {604800, "week"}, {86400, "day"}, {3600, "hour"}, {60, "minute"}, }
function timeSinceText(elapsed: number): string
if elapsed < 3 then
return "just now"
end
local ageText = string.format("%d seconds ago", elapsed)
for _,UnitData in ipairs(AGE_UNITS) do
local UnitSeconds, UnitName = UnitData[1], UnitData[2]
if elapsed > UnitSeconds then
local c = math.floor(elapsed/UnitSeconds)
ageText = string.format("%d %s%s ago", c, UnitName, c>1 and "s" or "")
break
end
end
return ageText
end
local function ChangesDrawer(props)
if props.rendered == false then
return nil
end
return Theme.with(function(theme)
return e(BorderedContainer, {
transparency = props.transparency,
size = props.height:map(function(y)
return UDim2.new(1, 0, y, -180 * y)
end),
position = UDim2.new(0, 0, 1, 0),
anchorPoint = Vector2.new(0, 1),
layoutOrder = props.layoutOrder,
}, {
Close = e(IconButton, {
icon = Assets.Images.Icons.Close,
iconSize = 24,
color = theme.ConnectionDetails.DisconnectColor,
transparency = props.transparency,
position = UDim2.new(1, 0, 0, 0),
anchorPoint = Vector2.new(1, 0),
onClick = props.onClose,
}, {
Tip = e(Tooltip.Trigger, {
text = "Close the patch visualizer"
}),
}),
PatchVisualizer = e(PatchVisualizer, {
size = UDim2.new(1, 0, 1, 0),
transparency = props.transparency,
layoutOrder = 3,
columnVisibility = {true, false, true},
patch = props.patchInfo:getValue().patch,
instanceMap = props.serveSession.__instanceMap,
}),
})
end)
end
local function ConnectionDetails(props) local function ConnectionDetails(props)
return Theme.with(function(theme) return Theme.with(function(theme)
return e(BorderedContainer, { return e(BorderedContainer, {
@@ -69,6 +139,10 @@ local function ConnectionDetails(props)
anchorPoint = Vector2.new(1, 0.5), anchorPoint = Vector2.new(1, 0.5),
onClick = props.onDisconnect, onClick = props.onDisconnect,
}, {
Tip = e(Tooltip.Trigger, {
text = "Disconnect from the Rojo sync server"
}),
}), }),
Padding = e("UIPadding", { Padding = e("UIPadding", {
@@ -81,36 +155,116 @@ end
local ConnectedPage = Roact.Component:extend("ConnectedPage") local ConnectedPage = Roact.Component:extend("ConnectedPage")
function ConnectedPage:render() function ConnectedPage:init()
return Roact.createFragment({ self.changeDrawerMotor = Flipper.SingleMotor.new(0)
Header = e(Header, { self.changeDrawerHeight = bindingUtil.fromMotor(self.changeDrawerMotor)
transparency = self.props.transparency,
layoutOrder = 1,
}),
ConnectionDetails = e(ConnectionDetails, { self.changeDrawerMotor:onStep(function(value)
projectName = self.state.projectName, local renderChanges = value > 0.05
address = self.state.address,
transparency = self.props.transparency,
layoutOrder = 2,
onDisconnect = self.props.onDisconnect, self:setState(function(state)
}), if state.renderChanges == renderChanges then
return nil
end
Layout = e("UIListLayout", { return {
VerticalAlignment = Enum.VerticalAlignment.Center, renderChanges = renderChanges,
FillDirection = Enum.FillDirection.Vertical, }
SortOrder = Enum.SortOrder.LayoutOrder, end)
Padding = UDim.new(0, 10), end)
}),
Padding = e("UIPadding", { self:setState({
PaddingLeft = UDim.new(0, 20), renderChanges = false,
PaddingRight = UDim.new(0, 20),
}),
}) })
end end
function ConnectedPage:render()
return Theme.with(function(theme)
return Roact.createFragment({
Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 20),
PaddingRight = UDim.new(0, 20),
}),
Layout = e("UIListLayout", {
VerticalAlignment = Enum.VerticalAlignment.Center,
FillDirection = Enum.FillDirection.Vertical,
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 10),
}),
Header = e(Header, {
transparency = self.props.transparency,
layoutOrder = 1,
}),
ConnectionDetails = e(ConnectionDetails, {
projectName = self.state.projectName,
address = self.state.address,
transparency = self.props.transparency,
layoutOrder = 2,
onDisconnect = self.props.onDisconnect,
}),
ChangeInfo = e("TextButton", {
Text = self.props.patchInfo:map(function(info)
local changes = PatchSet.countChanges(info.patch)
return string.format(
"<i>Synced %d change%s %s</i>",
changes,
changes == 1 and "" or "s",
timeSinceText(os.time() - info.timestamp)
)
end),
Font = Enum.Font.Gotham,
TextSize = 14,
TextWrapped = true,
RichText = true,
TextColor3 = theme.Header.VersionColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextYAlignment = Enum.TextYAlignment.Top,
TextTransparency = self.props.transparency,
Size = UDim2.new(1, 0, 0, 28),
LayoutOrder = 3,
BackgroundTransparency = 1,
[Roact.Event.Activated] = function()
if self.state.renderChanges then
self.changeDrawerMotor:setGoal(Flipper.Spring.new(0, {
frequency = 4,
dampingRatio = 1,
}))
else
self.changeDrawerMotor:setGoal(Flipper.Spring.new(1, {
frequency = 3,
dampingRatio = 1,
}))
end
end,
}),
ChangesDrawer = e(ChangesDrawer, {
rendered = self.state.renderChanges,
transparency = self.props.transparency,
patchInfo = self.props.patchInfo,
serveSession = self.props.serveSession,
height = self.changeDrawerHeight,
layoutOrder = 4,
onClose = function()
self.changeDrawerMotor:setGoal(Flipper.Spring.new(0, {
frequency = 4,
dampingRatio = 1,
}))
end,
}),
})
end)
end
function ConnectedPage.getDerivedStateFromProps(props) function ConnectedPage.getDerivedStateFromProps(props)
-- If projectName or address ever get removed from props, make sure we still have -- If projectName or address ever get removed from props, make sure we still have
-- the properties! The component still needs to have its data for it to be properly -- the properties! The component still needs to have its data for it to be properly
@@ -122,4 +276,4 @@ function ConnectedPage.getDerivedStateFromProps(props)
} }
end end
return ConnectedPage return ConnectedPage

View File

@@ -1,7 +1,8 @@
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Rojo.Roact) local Roact = require(Packages.Roact)
local Spinner = require(Plugin.App.Components.Spinner) local Spinner = require(Plugin.App.Components.Spinner)
@@ -17,4 +18,4 @@ function ConnectingPage:render()
}) })
end end
return ConnectingPage return ConnectingPage

View File

@@ -2,14 +2,16 @@ local TextService = game:GetService("TextService")
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Rojo.Roact) local Roact = require(Packages.Roact)
local Theme = require(Plugin.App.Theme) local Theme = require(Plugin.App.Theme)
local TextButton = require(Plugin.App.Components.TextButton) local TextButton = require(Plugin.App.Components.TextButton)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer) local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame) local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame)
local Tooltip = require(Plugin.App.Components.Tooltip)
local e = Roact.createElement local e = Roact.createElement
@@ -56,8 +58,16 @@ function Error:render()
end, end,
}, { }, {
ErrorMessage = Theme.with(function(theme) ErrorMessage = Theme.with(function(theme)
return e("TextLabel", { return e("TextBox", {
[Roact.Event.InputBegan] = function(rbx, input)
if input.UserInputType ~= Enum.UserInputType.MouseButton1 then return end
rbx.SelectionStart = 0
rbx.CursorPosition = #rbx.Text+1
end,
Text = self.props.errorMessage, Text = self.props.errorMessage,
TextEditable = false,
Font = Enum.Font.Code, Font = Enum.Font.Code,
TextSize = 16, TextSize = 16,
TextColor3 = theme.ErrorColor, TextColor3 = theme.ErrorColor,
@@ -65,10 +75,9 @@ function Error:render()
TextYAlignment = Enum.TextYAlignment.Top, TextYAlignment = Enum.TextYAlignment.Top,
TextTransparency = self.props.transparency, TextTransparency = self.props.transparency,
TextWrapped = true, TextWrapped = true,
ClearTextOnFocus = false,
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1, BackgroundTransparency = 1,
Size = UDim2.new(1, 0, 1, 0),
}) })
end), end),
@@ -115,6 +124,10 @@ function ErrorPage:render()
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 1, layoutOrder = 1,
onClick = self.props.onClose, onClick = self.props.onClose,
}, {
Tip = e(Tooltip.Trigger, {
text = "Dismiss message"
}),
}), }),
Layout = e("UIListLayout", { Layout = e("UIListLayout", {
@@ -150,4 +163,4 @@ function ErrorPage.getDerivedStateFromProps(props)
} }
end end
return ErrorPage return ErrorPage

View File

@@ -1,7 +1,8 @@
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Rojo.Roact) local Roact = require(Packages.Roact)
local Config = require(Plugin.Config) local Config = require(Plugin.Config)
@@ -9,6 +10,7 @@ local Theme = require(Plugin.App.Theme)
local BorderedContainer = require(Plugin.App.Components.BorderedContainer) local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
local TextButton = require(Plugin.App.Components.TextButton) local TextButton = require(Plugin.App.Components.TextButton)
local Header = require(Plugin.App.Components.Header) local Header = require(Plugin.App.Components.Header)
local Tooltip = require(Plugin.App.Components.Tooltip)
local PORT_WIDTH = 74 local PORT_WIDTH = 74
local DIVIDER_WIDTH = 1 local DIVIDER_WIDTH = 1
@@ -108,6 +110,7 @@ function NotConnectedPage:render()
Size = UDim2.new(1, 0, 0, 34), Size = UDim2.new(1, 0, 0, 34),
LayoutOrder = 3, LayoutOrder = 3,
BackgroundTransparency = 1, BackgroundTransparency = 1,
ZIndex = 2,
}, { }, {
Settings = e(TextButton, { Settings = e(TextButton, {
text = "Settings", text = "Settings",
@@ -115,6 +118,10 @@ function NotConnectedPage:render()
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 1, layoutOrder = 1,
onClick = self.props.onNavigateSettings, onClick = self.props.onNavigateSettings,
}, {
Tip = e(Tooltip.Trigger, {
text = "View and modify plugin settings"
}),
}), }),
Connect = e(TextButton, { Connect = e(TextButton, {
@@ -123,6 +130,10 @@ function NotConnectedPage:render()
transparency = self.props.transparency, transparency = self.props.transparency,
layoutOrder = 2, layoutOrder = 2,
onClick = self.props.onConnect, onClick = self.props.onConnect,
}, {
Tip = e(Tooltip.Trigger, {
text = "Connect to a Rojo sync server"
}),
}), }),
Layout = e("UIListLayout", { Layout = e("UIListLayout", {
@@ -148,4 +159,4 @@ function NotConnectedPage:render()
}) })
end end
return NotConnectedPage return NotConnectedPage

View File

@@ -1,238 +0,0 @@
local TextService = game:GetService("TextService")
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Roact = require(Rojo.Roact)
local Assets = require(Plugin.Assets)
local Theme = require(Plugin.App.Theme)
local PluginSettings = require(Plugin.App.PluginSettings)
local Checkbox = require(Plugin.App.Components.Checkbox)
local IconButton = require(Plugin.App.Components.IconButton)
local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame)
local e = Roact.createElement
local DIVIDER_FADE_SIZE = 0.1
local function getTextBounds(text, textSize, font, lineHeight, bounds)
local textBounds = TextService:GetTextSize(text, textSize, font, bounds)
local lineCount = textBounds.Y / textSize
local lineHeightAbsolute = textSize * lineHeight
return Vector2.new(textBounds.X, lineHeightAbsolute * lineCount - (lineHeightAbsolute - textSize))
end
local function Navbar(props)
return Theme.with(function(theme)
theme = theme.Settings.Navbar
return e("Frame", {
Size = UDim2.new(1, 0, 0, 46),
LayoutOrder = props.layoutOrder,
BackgroundTransparency = 1,
}, {
Back = e(IconButton, {
icon = Assets.Images.Icons.Back,
iconSize = 24,
color = theme.BackButtonColor,
transparency = props.transparency,
position = UDim2.new(0, 0, 0.5, 0),
anchorPoint = Vector2.new(0, 0.5),
onClick = props.onBack,
}),
Text = e("TextLabel", {
Text = "Settings",
Font = Enum.Font.Gotham,
TextSize = 18,
TextColor3 = theme.TextColor,
TextTransparency = props.transparency,
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
})
})
end)
end
local Setting = Roact.Component:extend("Setting")
function Setting:init()
self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0))
self.containerSize, self.setContainerSize = Roact.createBinding(Vector2.new(0, 0))
end
function Setting:render()
return Theme.with(function(theme)
theme = theme.Settings
return PluginSettings.with(function(settings)
return e("Frame", {
Size = self.contentSize:map(function(value)
return UDim2.new(1, 0, 0, 20 + value.Y + 20)
end),
LayoutOrder = self.props.layoutOrder,
BackgroundTransparency = 1,
[Roact.Change.AbsoluteSize] = function(object)
self.setContainerSize(object.AbsoluteSize)
end,
}, {
Checkbox = e(Checkbox, {
active = settings:get(self.props.id),
transparency = self.props.transparency,
position = UDim2.new(1, 0, 0.5, 0),
anchorPoint = Vector2.new(1, 0.5),
onClick = function()
local currentValue = settings:get(self.props.id)
settings:set(self.props.id, not currentValue)
end,
}),
Text = e("Frame", {
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
}, {
Name = e("TextLabel", {
Text = self.props.name,
Font = Enum.Font.GothamBold,
TextSize = 17,
TextColor3 = theme.Setting.NameColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = self.props.transparency,
Size = UDim2.new(1, 0, 0, 17),
LayoutOrder = 1,
BackgroundTransparency = 1,
}),
Description = e("TextLabel", {
Text = self.props.description,
Font = Enum.Font.Gotham,
LineHeight = 1.2,
TextSize = 14,
TextColor3 = theme.Setting.DescriptionColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = self.props.transparency,
TextWrapped = true,
Size = self.containerSize:map(function(value)
local textBounds = getTextBounds(
self.props.description, 14, Enum.Font.Gotham, 1.2,
Vector2.new(value.X - 50, math.huge)
)
return UDim2.new(1, -50, 0, textBounds.Y)
end),
LayoutOrder = 2,
BackgroundTransparency = 1,
}),
Layout = e("UIListLayout", {
VerticalAlignment = Enum.VerticalAlignment.Center,
FillDirection = Enum.FillDirection.Vertical,
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 6),
[Roact.Change.AbsoluteContentSize] = function(object)
self.setContentSize(object.AbsoluteContentSize)
end,
}),
Padding = e("UIPadding", {
PaddingTop = UDim.new(0, 20),
PaddingBottom = UDim.new(0, 20),
}),
}),
Divider = e("Frame", {
BackgroundColor3 = theme.DividerColor,
BackgroundTransparency = self.props.transparency,
Size = UDim2.new(1, 0, 0, 1),
BorderSizePixel = 0,
}, {
Gradient = e("UIGradient", {
Transparency = NumberSequence.new({
NumberSequenceKeypoint.new(0, 1),
NumberSequenceKeypoint.new(DIVIDER_FADE_SIZE, 0),
NumberSequenceKeypoint.new(1 - DIVIDER_FADE_SIZE, 0),
NumberSequenceKeypoint.new(1, 1),
}),
}),
}),
})
end)
end)
end
local SettingsPage = Roact.Component:extend("SettingsPage")
function SettingsPage:init()
self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0))
end
function SettingsPage:render()
return Theme.with(function(theme)
theme = theme.Settings
return e(ScrollingFrame, {
size = UDim2.new(1, 0, 1, 0),
contentSize = self.contentSize,
transparency = self.props.transparency,
}, {
Navbar = e(Navbar, {
onBack = self.props.onBack,
transparency = self.props.transparency,
layoutOrder = 0,
}),
OpenScriptsExternally = e(Setting, {
id = "openScriptsExternally",
name = "Open Scripts Externally",
description = "Attempt to open scripts in an external editor",
transparency = self.props.transparency,
layoutOrder = 1,
}),
ShowNotifications = e(Setting, {
id = "showNotifications",
name = "Show Notifications",
description = "Popup notifications in viewport",
transparency = self.props.transparency,
layoutOrder = 2,
}),
TwoWaySync = e(Setting, {
id = "twoWaySync",
name = "Two-Way Sync",
description = "EXPERIMENTAL! Editing files in Studio will sync them into the filesystem",
transparency = self.props.transparency,
layoutOrder = 3,
}),
Layout = e("UIListLayout", {
FillDirection = Enum.FillDirection.Vertical,
SortOrder = Enum.SortOrder.LayoutOrder,
[Roact.Change.AbsoluteContentSize] = function(object)
self.setContentSize(object.AbsoluteContentSize)
end,
}),
Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 20),
PaddingRight = UDim.new(0, 20),
}),
})
end)
end
return SettingsPage

View File

@@ -0,0 +1,180 @@
local TextService = game:GetService("TextService")
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Settings = require(Plugin.Settings)
local Assets = require(Plugin.Assets)
local Theme = require(Plugin.App.Theme)
local Checkbox = require(Plugin.App.Components.Checkbox)
local Dropdown = require(Plugin.App.Components.Dropdown)
local IconButton = require(Plugin.App.Components.IconButton)
local e = Roact.createElement
local DIVIDER_FADE_SIZE = 0.1
local function getTextBounds(text, textSize, font, lineHeight, bounds)
local textBounds = TextService:GetTextSize(text, textSize, font, bounds)
local lineCount = textBounds.Y / textSize
local lineHeightAbsolute = textSize * lineHeight
return Vector2.new(textBounds.X, lineHeightAbsolute * lineCount - (lineHeightAbsolute - textSize))
end
local Setting = Roact.Component:extend("Setting")
function Setting:init()
self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0))
self.containerSize, self.setContainerSize = Roact.createBinding(Vector2.new(0, 0))
self:setState({
setting = Settings:get(self.props.id),
})
self.changedCleanup = Settings:onChanged(self.props.id, function(value)
self:setState({
setting = value,
})
end)
end
function Setting:willUnmount()
self.changedCleanup()
end
function Setting:render()
return Theme.with(function(theme)
theme = theme.Settings
return e("Frame", {
Size = self.contentSize:map(function(value)
return UDim2.new(1, 0, 0, 20 + value.Y + 20)
end),
LayoutOrder = self.props.layoutOrder,
ZIndex = -self.props.layoutOrder,
BackgroundTransparency = 1,
[Roact.Change.AbsoluteSize] = function(object)
self.setContainerSize(object.AbsoluteSize)
end,
}, {
Input = if self.props.options ~= nil then
e(Dropdown, {
options = self.props.options,
active = self.state.setting,
transparency = self.props.transparency,
position = UDim2.new(1, 0, 0.5, 0),
anchorPoint = Vector2.new(1, 0.5),
onClick = function(option)
Settings:set(self.props.id, option)
end,
})
else
e(Checkbox, {
active = self.state.setting,
transparency = self.props.transparency,
position = UDim2.new(1, 0, 0.5, 0),
anchorPoint = Vector2.new(1, 0.5),
onClick = function()
local currentValue = Settings:get(self.props.id)
Settings:set(self.props.id, not currentValue)
end,
}),
Reset = if self.props.onReset then e(IconButton, {
icon = Assets.Images.Icons.Reset,
iconSize = 24,
color = theme.BackButtonColor,
transparency = self.props.transparency,
visible = self.props.showReset,
position = UDim2.new(1, -32 - (self.props.options ~= nil and 120 or 40), 0.5, 0),
anchorPoint = Vector2.new(0, 0.5),
onClick = self.props.onReset,
}) else nil,
Text = e("Frame", {
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
}, {
Name = e("TextLabel", {
Text = self.props.name,
Font = Enum.Font.GothamBold,
TextSize = 17,
TextColor3 = theme.Setting.NameColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = self.props.transparency,
Size = UDim2.new(1, 0, 0, 17),
LayoutOrder = 1,
BackgroundTransparency = 1,
}),
Description = e("TextLabel", {
Text = self.props.description,
Font = Enum.Font.Gotham,
LineHeight = 1.2,
TextSize = 14,
TextColor3 = theme.Setting.DescriptionColor,
TextXAlignment = Enum.TextXAlignment.Left,
TextTransparency = self.props.transparency,
TextWrapped = true,
Size = self.containerSize:map(function(value)
local offset = (self.props.onReset and 34 or 0) + (self.props.options ~= nil and 120 or 40)
local textBounds = getTextBounds(
self.props.description, 14, Enum.Font.Gotham, 1.2,
Vector2.new(value.X - offset, math.huge)
)
return UDim2.new(1, -offset, 0, textBounds.Y)
end),
LayoutOrder = 2,
BackgroundTransparency = 1,
}),
Layout = e("UIListLayout", {
VerticalAlignment = Enum.VerticalAlignment.Center,
FillDirection = Enum.FillDirection.Vertical,
SortOrder = Enum.SortOrder.LayoutOrder,
Padding = UDim.new(0, 6),
[Roact.Change.AbsoluteContentSize] = function(object)
self.setContentSize(object.AbsoluteContentSize)
end,
}),
Padding = e("UIPadding", {
PaddingTop = UDim.new(0, 20),
PaddingBottom = UDim.new(0, 20),
}),
}),
Divider = e("Frame", {
BackgroundColor3 = theme.DividerColor,
BackgroundTransparency = self.props.transparency,
Size = UDim2.new(1, 0, 0, 1),
BorderSizePixel = 0,
}, {
Gradient = e("UIGradient", {
Transparency = NumberSequence.new({
NumberSequenceKeypoint.new(0, 1),
NumberSequenceKeypoint.new(DIVIDER_FADE_SIZE, 0),
NumberSequenceKeypoint.new(1 - DIVIDER_FADE_SIZE, 0),
NumberSequenceKeypoint.new(1, 1),
}),
}),
}),
})
end)
end
return Setting

View File

@@ -0,0 +1,163 @@
local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Packages.Roact)
local Log = require(Packages.Log)
local Assets = require(Plugin.Assets)
local Settings = require(Plugin.Settings)
local Theme = require(Plugin.App.Theme)
local IconButton = require(Plugin.App.Components.IconButton)
local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame)
local Tooltip = require(Plugin.App.Components.Tooltip)
local Setting = require(script.Setting)
local e = Roact.createElement
local function invertTbl(tbl)
local new = {}
for key, value in tbl do
new[value] = key
end
return new
end
local invertedLevels = invertTbl(Log.Level)
local function Navbar(props)
return Theme.with(function(theme)
theme = theme.Settings.Navbar
return e("Frame", {
Size = UDim2.new(1, 0, 0, 46),
LayoutOrder = props.layoutOrder,
BackgroundTransparency = 1,
}, {
Back = e(IconButton, {
icon = Assets.Images.Icons.Back,
iconSize = 24,
color = theme.BackButtonColor,
transparency = props.transparency,
position = UDim2.new(0, 0, 0.5, 0),
anchorPoint = Vector2.new(0, 0.5),
onClick = props.onBack,
}, {
Tip = e(Tooltip.Trigger, {
text = "Back"
}),
}),
Text = e("TextLabel", {
Text = "Settings",
Font = Enum.Font.Gotham,
TextSize = 18,
TextColor3 = theme.TextColor,
TextTransparency = props.transparency,
Size = UDim2.new(1, 0, 1, 0),
BackgroundTransparency = 1,
})
})
end)
end
local SettingsPage = Roact.Component:extend("SettingsPage")
function SettingsPage:init()
self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0))
end
function SettingsPage:render()
return Theme.with(function(theme)
theme = theme.Settings
return e(ScrollingFrame, {
size = UDim2.new(1, 0, 1, 0),
contentSize = self.contentSize,
transparency = self.props.transparency,
}, {
Navbar = e(Navbar, {
onBack = self.props.onBack,
transparency = self.props.transparency,
layoutOrder = 0,
}),
OpenScriptsExternally = e(Setting, {
id = "openScriptsExternally",
name = "Open Scripts Externally",
description = "Attempt to open scripts in an external editor",
transparency = self.props.transparency,
layoutOrder = 1,
}),
ShowNotifications = e(Setting, {
id = "showNotifications",
name = "Show Notifications",
description = "Popup notifications in viewport",
transparency = self.props.transparency,
layoutOrder = 2,
}),
PlaySounds = e(Setting, {
id = "playSounds",
name = "Play Sounds",
description = "Toggle sound effects",
transparency = self.props.transparency,
layoutOrder = 3,
}),
TwoWaySync = e(Setting, {
id = "twoWaySync",
name = "Two-Way Sync",
description = "EXPERIMENTAL! Editing files in Studio will sync them into the filesystem",
transparency = self.props.transparency,
layoutOrder = 4,
}),
LogLevel = e(Setting, {
id = "logLevel",
name = "Log Level",
description = "Plugin output verbosity level",
transparency = self.props.transparency,
layoutOrder = 5,
options = invertedLevels,
showReset = Settings:getBinding("logLevel"):map(function(value)
return value ~= "Info"
end),
onReset = function()
Settings:set("logLevel", "Info")
end,
}),
TypecheckingEnabled = e(Setting, {
id = "typecheckingEnabled",
name = "Typechecking",
description = "Toggle typechecking on the API surface",
transparency = self.props.transparency,
layoutOrder = 6,
}),
Layout = e("UIListLayout", {
FillDirection = Enum.FillDirection.Vertical,
SortOrder = Enum.SortOrder.LayoutOrder,
[Roact.Change.AbsoluteContentSize] = function(object)
self.setContentSize(object.AbsoluteContentSize)
end,
}),
Padding = e("UIPadding", {
PaddingLeft = UDim.new(0, 20),
PaddingRight = UDim.new(0, 20),
}),
})
end)
end
return SettingsPage

View File

@@ -2,6 +2,7 @@ return {
NotConnected = require(script.NotConnected), NotConnected = require(script.NotConnected),
Settings = require(script.Settings), Settings = require(script.Settings),
Connecting = require(script.Connecting), Connecting = require(script.Connecting),
Confirming = require(script.Confirming),
Connected = require(script.Connected), Connected = require(script.Connected),
Error = require(script.Error), Error = require(script.Error),
} }

View File

@@ -16,9 +16,10 @@ local function getStudio()
end end
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Packages = Rojo.Packages
local Roact = require(Rojo.Roact) local Roact = require(Packages.Roact)
local Log = require(Rojo.Log) local Log = require(Packages.Log)
local strict = require(script.Parent.Parent.strict) local strict = require(script.Parent.Parent.strict)
@@ -71,6 +72,17 @@ local lightTheme = strict("LightTheme", {
BorderColor = hexColor(0xAFAFAF), BorderColor = hexColor(0xAFAFAF),
}, },
}, },
Dropdown = {
TextColor = hexColor(0x00000),
BorderColor = hexColor(0xAFAFAF),
BackgroundColor = hexColor(0xEEEEEE),
Open = {
IconColor = BRAND_COLOR,
},
Closed = {
IconColor = hexColor(0xEEEEEE),
},
},
AddressEntry = { AddressEntry = {
TextColor = hexColor(0x000000), TextColor = hexColor(0x000000),
PlaceholderColor = hexColor(0x8C8C8C) PlaceholderColor = hexColor(0x8C8C8C)
@@ -83,6 +95,12 @@ local lightTheme = strict("LightTheme", {
ForegroundColor = BRAND_COLOR, ForegroundColor = BRAND_COLOR,
BackgroundColor = hexColor(0xEEEEEE), BackgroundColor = hexColor(0xEEEEEE),
}, },
Diff = {
Add = hexColor(0xbaffbd),
Remove = hexColor(0xffbdba),
Edit = hexColor(0xbacdff),
Row = hexColor(0x000000),
},
ConnectionDetails = { ConnectionDetails = {
ProjectNameColor = hexColor(0x00000), ProjectNameColor = hexColor(0x00000),
AddressColor = hexColor(0x00000), AddressColor = hexColor(0x00000),
@@ -149,6 +167,17 @@ local darkTheme = strict("DarkTheme", {
BorderColor = hexColor(0x5A5A5A), BorderColor = hexColor(0x5A5A5A),
}, },
}, },
Dropdown = {
TextColor = hexColor(0xFFFFFF),
BorderColor = hexColor(0x5A5A5A),
BackgroundColor = hexColor(0x2B2B2B),
Open = {
IconColor = BRAND_COLOR,
},
Closed = {
IconColor = hexColor(0x484848),
},
},
AddressEntry = { AddressEntry = {
TextColor = hexColor(0xFFFFFF), TextColor = hexColor(0xFFFFFF),
PlaceholderColor = hexColor(0x8B8B8B) PlaceholderColor = hexColor(0x8B8B8B)
@@ -161,6 +190,12 @@ local darkTheme = strict("DarkTheme", {
ForegroundColor = BRAND_COLOR, ForegroundColor = BRAND_COLOR,
BackgroundColor = hexColor(0x2B2B2B), BackgroundColor = hexColor(0x2B2B2B),
}, },
Diff = {
Add = hexColor(0x273732),
Remove = hexColor(0x3F2D32),
Edit = hexColor(0x193345),
Row = hexColor(0xFFFFFF),
},
ConnectionDetails = { ConnectionDetails = {
ProjectNameColor = hexColor(0xFFFFFF), ProjectNameColor = hexColor(0xFFFFFF),
AddressColor = hexColor(0xFFFFFF), AddressColor = hexColor(0xFFFFFF),

View File

@@ -1,7 +1,8 @@
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Packages = Rojo.Packages
local Roact = require(Rojo.Roact) local Roact = require(Packages.Roact)
local Log = require(Rojo.Log) local Log = require(Packages.Log)
local LERP_DATA_TYPES = { local LERP_DATA_TYPES = {
Color3 = true, Color3 = true,
@@ -55,4 +56,4 @@ return {
mapLerp = mapLerp, mapLerp = mapLerp,
deriveProperty = deriveProperty, deriveProperty = deriveProperty,
blendAlpha = blendAlpha, blendAlpha = blendAlpha,
} }

View File

@@ -1,22 +1,29 @@
local Players = game:GetService("Players")
local ServerStorage = game:GetService("ServerStorage")
local Rojo = script:FindFirstAncestor("Rojo") local Rojo = script:FindFirstAncestor("Rojo")
local Plugin = Rojo.Plugin local Plugin = Rojo.Plugin
local Packages = Rojo.Packages
local Roact = require(Rojo.Roact) local Roact = require(Packages.Roact)
local Log = require(Rojo.Log) local Log = require(Packages.Log)
local Assets = require(Plugin.Assets) local Assets = require(Plugin.Assets)
local Version = require(Plugin.Version) local Version = require(Plugin.Version)
local Config = require(Plugin.Config) local Config = require(Plugin.Config)
local Settings = require(Plugin.Settings)
local strict = require(Plugin.strict) local strict = require(Plugin.strict)
local Dictionary = require(Plugin.Dictionary) local Dictionary = require(Plugin.Dictionary)
local ServeSession = require(Plugin.ServeSession) local ServeSession = require(Plugin.ServeSession)
local ApiContext = require(Plugin.ApiContext) local ApiContext = require(Plugin.ApiContext)
local PatchSet = require(Plugin.PatchSet)
local preloadAssets = require(Plugin.preloadAssets) local preloadAssets = require(Plugin.preloadAssets)
local soundPlayer = require(Plugin.soundPlayer)
local Theme = require(script.Theme) local Theme = require(script.Theme)
local PluginSettings = require(script.PluginSettings)
local Page = require(script.Page) local Page = require(script.Page)
local Notifications = require(script.Notifications) local Notifications = require(script.Notifications)
local Tooltip = require(script.Components.Tooltip)
local StudioPluginAction = require(script.Components.Studio.StudioPluginAction) local StudioPluginAction = require(script.Components.Studio.StudioPluginAction)
local StudioToolbar = require(script.Components.Studio.StudioToolbar) local StudioToolbar = require(script.Components.Studio.StudioToolbar)
local StudioToggleButton = require(script.Components.Studio.StudioToggleButton) local StudioToggleButton = require(script.Components.Studio.StudioToggleButton)
@@ -28,6 +35,7 @@ local AppStatus = strict("AppStatus", {
NotConnected = "NotConnected", NotConnected = "NotConnected",
Settings = "Settings", Settings = "Settings",
Connecting = "Connecting", Connecting = "Connecting",
Confirming = "Confirming",
Connected = "Connected", Connected = "Connected",
Error = "Error", Error = "Error",
}) })
@@ -39,19 +47,28 @@ local App = Roact.Component:extend("App")
function App:init() function App:init()
preloadAssets() preloadAssets()
self.host, self.setHost = Roact.createBinding("") local priorHost, priorPort = self:getPriorEndpoint()
self.port, self.setPort = Roact.createBinding("") self.host, self.setHost = Roact.createBinding(priorHost or "")
self.port, self.setPort = Roact.createBinding(priorPort or "")
self.patchInfo, self.setPatchInfo = Roact.createBinding({
patch = PatchSet.newEmpty(),
timestamp = os.time(),
})
self.confirmationBindable = Instance.new("BindableEvent")
self.confirmationEvent = self.confirmationBindable.Event
self:setState({ self:setState({
appStatus = AppStatus.NotConnected, appStatus = AppStatus.NotConnected,
guiEnabled = false, guiEnabled = false,
confirmData = {},
notifications = {}, notifications = {},
toolbarIcon = Assets.Images.PluginButton, toolbarIcon = Assets.Images.PluginButton,
}) })
end end
function App:addNotification(text: string, timeout: number?) function App:addNotification(text: string, timeout: number?)
if not self.props.settings:get("showNotifications") then if not Settings:get("showNotifications") then
return return
end end
@@ -76,6 +93,45 @@ function App:closeNotification(index: number)
}) })
end end
function App:getPriorEndpoint()
local priorEndpoints = Settings:get("priorEndpoints")
if not priorEndpoints then return end
local place = priorEndpoints[tostring(game.PlaceId)]
if not place then return end
return place.host, place.port
end
function App:setPriorEndpoint(host: string, port: string)
local priorEndpoints = Settings:get("priorEndpoints")
if not priorEndpoints then
priorEndpoints = {}
end
-- Clear any stale saves to avoid disc bloat
for placeId, endpoint in priorEndpoints do
if os.time() - endpoint.timestamp > 12_960_000 then
priorEndpoints[placeId] = nil
Log.trace("Cleared stale saved endpoint for {}", placeId)
end
end
if host == Config.defaultHost and port == Config.defaultPort then
-- Don't save default
priorEndpoints[tostring(game.PlaceId)] = nil
else
priorEndpoints[tostring(game.PlaceId)] = {
host = host ~= Config.defaultHost and host or nil,
port = port ~= Config.defaultPort and port or nil,
timestamp = os.time(),
}
Log.trace("Saved last used endpoint for {}", game.PlaceId)
end
Settings:set("priorEndpoints", priorEndpoints)
end
function App:getHostAndPort() function App:getHostAndPort()
local host = self.host:getValue() local host = self.host:getValue()
local port = self.port:getValue() local port = self.port:getValue()
@@ -86,15 +142,75 @@ function App:getHostAndPort()
return host, port return host, port
end end
function App:claimSyncLock()
if #Players:GetPlayers() == 0 then
Log.trace("Skipping sync lock because this isn't in Team Create")
return true
end
local lock = ServerStorage:FindFirstChild("__Rojo_SessionLock")
if not lock then
lock = Instance.new("ObjectValue")
lock.Name = "__Rojo_SessionLock"
lock.Archivable = false
lock.Value = Players.LocalPlayer
lock.Parent = ServerStorage
Log.trace("Created and claimed sync lock")
return true
end
if lock.Value and lock.Value ~= Players.LocalPlayer and lock.Value.Parent then
Log.trace("Found existing sync lock owned by {}", lock.Value)
return false, lock.Value
end
lock.Value = Players.LocalPlayer
Log.trace("Claimed existing sync lock")
return true
end
function App:releaseSyncLock()
local lock = ServerStorage:FindFirstChild("__Rojo_SessionLock")
if not lock then
Log.trace("No sync lock found, assumed released")
return
end
if lock.Value == Players.LocalPlayer then
lock.Value = nil
Log.trace("Released sync lock")
return
end
Log.trace("Could not relase sync lock because it is owned by {}", lock.Value)
end
function App:startSession() function App:startSession()
local claimedLock, priorOwner = self:claimSyncLock()
if not claimedLock then
local msg = string.format("Could not sync because user '%s' is already syncing", tostring(priorOwner))
Log.warn(msg)
self:addNotification(msg, 10)
self:setState({
appStatus = AppStatus.Error,
errorMessage = msg,
toolbarIcon = Assets.Images.PluginButtonWarning,
})
return
end
local host, port = self:getHostAndPort() local host, port = self:getHostAndPort()
local sessionOptions = { local sessionOptions = {
openScriptsExternally = self.props.settings:get("openScriptsExternally"), openScriptsExternally = Settings:get("openScriptsExternally"),
twoWaySync = self.props.settings:get("twoWaySync"), twoWaySync = Settings:get("twoWaySync"),
} }
local baseUrl = ("http://%s:%s"):format(host, port) local baseUrl = if string.find(host, "^https?://")
then string.format("%s:%s", host, port)
else string.format("http://%s:%s", host, port)
local apiContext = ApiContext.new(baseUrl) local apiContext = ApiContext.new(baseUrl)
local serveSession = ServeSession.new({ local serveSession = ServeSession.new({
@@ -103,8 +219,37 @@ function App:startSession()
twoWaySync = sessionOptions.twoWaySync, twoWaySync = sessionOptions.twoWaySync,
}) })
serveSession:onPatchApplied(function(patch, _unapplied)
if PatchSet.isEmpty(patch) then
-- Ignore empty patches
return
end
local now = os.time()
local old = self.patchInfo:getValue()
if now - old.timestamp < 2 then
-- Patches that apply in the same second are
-- considered to be part of the same change for human clarity
local merged = PatchSet.newEmpty()
PatchSet.assign(merged, old.patch, patch)
self.setPatchInfo({
patch = merged,
timestamp = now,
})
else
self.setPatchInfo({
patch = patch,
timestamp = now,
})
end
end)
serveSession:onStatusChanged(function(status, details) serveSession:onStatusChanged(function(status, details)
if status == ServeSession.Status.Connecting then if status == ServeSession.Status.Connecting then
self:setPriorEndpoint(host, port)
self:setState({ self:setState({
appStatus = AppStatus.Connecting, appStatus = AppStatus.Connecting,
toolbarIcon = Assets.Images.PluginButton, toolbarIcon = Assets.Images.PluginButton,
@@ -121,6 +266,7 @@ function App:startSession()
self:addNotification(string.format("Connected to session '%s' at %s.", details, address), 5) self:addNotification(string.format("Connected to session '%s' at %s.", details, address), 5)
elseif status == ServeSession.Status.Disconnected then elseif status == ServeSession.Status.Disconnected then
self.serveSession = nil self.serveSession = nil
self:releaseSyncLock()
-- Details being present indicates that this -- Details being present indicates that this
-- disconnection was from an error. -- disconnection was from an error.
@@ -143,9 +289,45 @@ function App:startSession()
end end
end) end)
serveSession:setConfirmCallback(function(instanceMap, patch, serverInfo)
if PatchSet.isEmpty(patch) then
return "Accept"
end
self:setState({
appStatus = AppStatus.Confirming,
confirmData = {
instanceMap = instanceMap,
patch = patch,
serverInfo = serverInfo,
},
toolbarIcon = Assets.Images.PluginButton,
})
self:addNotification(
string.format(
"Please accept%sor abort the initializing sync session.",
Settings:get("twoWaySync") and ", reject, " or " "
),
7
)
return self.confirmationEvent:Wait()
end)
serveSession:start() serveSession:start()
self.serveSession = serveSession self.serveSession = serveSession
task.defer(function()
while self.serveSession == serveSession do
-- Trigger rerender to update timestamp text
local patchInfo = table.clone(self.patchInfo:getValue())
self.setPatchInfo(patchInfo)
local elapsed = os.time() - patchInfo.timestamp
task.wait(elapsed < 60 and 1 or elapsed / 5)
end
end)
end end
function App:endSession() function App:endSession()
@@ -182,100 +364,119 @@ function App:render()
value = self.props.plugin, value = self.props.plugin,
}, { }, {
e(Theme.StudioProvider, nil, { e(Theme.StudioProvider, nil, {
gui = e(StudioPluginGui, { e(Tooltip.Provider, nil, {
id = pluginName, gui = e(StudioPluginGui, {
title = pluginName, id = pluginName,
active = self.state.guiEnabled, title = pluginName,
active = self.state.guiEnabled,
initDockState = Enum.InitialDockState.Right, initDockState = Enum.InitialDockState.Right,
initEnabled = false, initEnabled = false,
overridePreviousState = false, overridePreviousState = false,
floatingSize = Vector2.new(300, 200), floatingSize = Vector2.new(300, 200),
minimumSize = Vector2.new(300, 200), minimumSize = Vector2.new(300, 120),
zIndexBehavior = Enum.ZIndexBehavior.Sibling, zIndexBehavior = Enum.ZIndexBehavior.Sibling,
onInitialState = function(initialState) onInitialState = function(initialState)
self:setState({
guiEnabled = initialState,
})
end,
onClose = function()
self:setState({
guiEnabled = false,
})
end,
}, {
NotConnectedPage = createPageElement(AppStatus.NotConnected, {
host = self.host,
onHostChange = self.setHost,
port = self.port,
onPortChange = self.setPort,
onConnect = function()
self:startSession()
end,
onNavigateSettings = function()
self:setState({ self:setState({
appStatus = AppStatus.Settings, guiEnabled = initialState,
}) })
end, end,
}),
Connecting = createPageElement(AppStatus.Connecting),
Connected = createPageElement(AppStatus.Connected, {
projectName = self.state.projectName,
address = self.state.address,
onDisconnect = function()
self:endSession()
end,
}),
Settings = createPageElement(AppStatus.Settings, {
onBack = function()
self:setState({
appStatus = AppStatus.NotConnected,
})
end,
}),
Error = createPageElement(AppStatus.Error, {
errorMessage = self.state.errorMessage,
onClose = function() onClose = function()
self:setState({ self:setState({
appStatus = AppStatus.NotConnected, guiEnabled = false,
toolbarIcon = Assets.Images.PluginButton,
}) })
end, end,
}, {
Tooltips = e(Tooltip.Container, nil),
NotConnectedPage = createPageElement(AppStatus.NotConnected, {
host = self.host,
onHostChange = self.setHost,
port = self.port,
onPortChange = self.setPort,
onConnect = function()
self:startSession()
end,
onNavigateSettings = function()
self:setState({
appStatus = AppStatus.Settings,
})
end,
}),
ConfirmingPage = createPageElement(AppStatus.Confirming, {
confirmData = self.state.confirmData,
createPopup = not self.state.guiEnabled,
onAbort = function()
self.confirmationBindable:Fire("Abort")
end,
onAccept = function()
self.confirmationBindable:Fire("Accept")
end,
onReject = function()
self.confirmationBindable:Fire("Reject")
end,
}),
Connecting = createPageElement(AppStatus.Connecting),
Connected = createPageElement(AppStatus.Connected, {
projectName = self.state.projectName,
address = self.state.address,
patchInfo = self.patchInfo,
serveSession = self.serveSession,
onDisconnect = function()
self:endSession()
end,
}),
Settings = createPageElement(AppStatus.Settings, {
onBack = function()
self:setState({
appStatus = AppStatus.NotConnected,
})
end,
}),
Error = createPageElement(AppStatus.Error, {
errorMessage = self.state.errorMessage,
onClose = function()
self:setState({
appStatus = AppStatus.NotConnected,
toolbarIcon = Assets.Images.PluginButton,
})
end,
}),
}), }),
Background = Theme.with(function(theme) RojoNotifications = e("ScreenGui", {}, {
return e("Frame", { layout = e("UIListLayout", {
Size = UDim2.new(1, 0, 1, 0), SortOrder = Enum.SortOrder.LayoutOrder,
BackgroundColor3 = theme.BackgroundColor, HorizontalAlignment = Enum.HorizontalAlignment.Right,
ZIndex = 0, VerticalAlignment = Enum.VerticalAlignment.Bottom,
BorderSizePixel = 0, Padding = UDim.new(0, 5),
}) }),
end), padding = e("UIPadding", {
}), PaddingTop = UDim.new(0, 5),
PaddingBottom = UDim.new(0, 5),
RojoNotifications = e("ScreenGui", {}, { PaddingLeft = UDim.new(0, 5),
layout = e("UIListLayout", { PaddingRight = UDim.new(0, 5),
SortOrder = Enum.SortOrder.LayoutOrder, }),
HorizontalAlignment = Enum.HorizontalAlignment.Right, notifs = e(Notifications, {
VerticalAlignment = Enum.VerticalAlignment.Bottom, soundPlayer = self.props.soundPlayer,
Padding = UDim.new(0, 5), notifications = self.state.notifications,
}), onClose = function(index)
notifs = e(Notifications, { self:closeNotification(index)
notifications = self.state.notifications, end,
onClose = function(index) }),
self:closeNotification(index)
end,
}), }),
}), }),
@@ -288,7 +489,9 @@ function App:render()
onTriggered = function() onTriggered = function()
if self.serveSession == nil or self.serveSession:getStatus() == ServeSession.Status.NotStarted then if self.serveSession == nil or self.serveSession:getStatus() == ServeSession.Status.NotStarted then
self:startSession() self:startSession()
elseif self.serveSession ~= nil and self.serveSession:getStatus() == ServeSession.Status.Connected then elseif
self.serveSession ~= nil and self.serveSession:getStatus() == ServeSession.Status.Connected
then
self:endSession() self:endSession()
end end
end, end,
@@ -336,21 +539,16 @@ function App:render()
} }
end) end)
end, end,
}) }),
}), }),
}), }),
}) })
end end
return function(props) return function(props)
return e(PluginSettings.StudioProvider, { local mergedProps = Dictionary.merge(props, {
plugin = props.plugin, soundPlayer = soundPlayer.new(Settings),
}, {
App = PluginSettings.with(function(settings)
local settingsProps = Dictionary.merge(props, {
settings = settings,
})
return e(App, settingsProps)
end),
}) })
return e(App, mergedProps)
end end

View File

@@ -23,11 +23,20 @@ local Assets = {
Icons = { Icons = {
Close = "rbxassetid://6012985953", Close = "rbxassetid://6012985953",
Back = "rbxassetid://6017213752", Back = "rbxassetid://6017213752",
Reset = "rbxassetid://10142422327",
},
Diff = {
Add = "rbxassetid://10434145835",
Remove = "rbxassetid://10434408368",
Edit = "rbxassetid://10434144680",
}, },
Checkbox = { Checkbox = {
Active = "rbxassetid://6016251644", Active = "rbxassetid://6016251644",
Inactive = "rbxassetid://6016251963", Inactive = "rbxassetid://6016251963",
}, },
Dropdown = {
Arrow = "rbxassetid://10131770538",
},
Spinner = { Spinner = {
Foreground = "rbxassetid://3222731032", Foreground = "rbxassetid://3222731032",
Background = "rbxassetid://3222730627", Background = "rbxassetid://3222730627",
@@ -46,7 +55,7 @@ local Assets = {
}, },
}, },
Sounds = { Sounds = {
Notification = "rbxassetid://9716079936", Notification = "rbxassetid://203785492",
}, },
StartSession = "", StartSession = "",
SessionActive = "", SessionActive = "",

View File

@@ -5,7 +5,8 @@
of instances) and return the patch. of instances) and return the patch.
]] ]]
local Log = require(script.Parent.Parent.Parent.Log) local Packages = script.Parent.Parent.Parent.Packages
local Log = require(Packages.Log)
local PatchSet = require(script.Parent.Parent.PatchSet) local PatchSet = require(script.Parent.Parent.PatchSet)

View File

@@ -1,5 +1,6 @@
local Log = require(script.Parent.Parent.Parent.Log) local Packages = script.Parent.Parent.Parent.Packages
local RbxDom = require(script.Parent.Parent.Parent.RbxDom) local Log = require(Packages.Log)
local RbxDom = require(Packages.RbxDom)
local encodeProperty = require(script.Parent.encodeProperty) local encodeProperty = require(script.Parent.encodeProperty)

View File

@@ -1,5 +1,6 @@
local Log = require(script.Parent.Parent.Parent.Log) local Packages = script.Parent.Parent.Parent.Packages
local RbxDom = require(script.Parent.Parent.Parent.RbxDom) local Log = require(Packages.Log)
local RbxDom = require(Packages.RbxDom)
return function(instance, propertyName, propertyDescriptor) return function(instance, propertyName, propertyDescriptor)
local readSuccess, readResult = propertyDescriptor:read(instance) local readSuccess, readResult = propertyDescriptor:read(instance)

View File

@@ -5,9 +5,9 @@ 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 = {7, 2, 0}, version = {7, 2, 1},
expectedServerVersionString = "7.0 or newer", expectedServerVersionString = "7.2 or newer",
protocolVersion = 4, protocolVersion = 4,
defaultHost = "localhost", defaultHost = "localhost",
defaultPort = 34872, defaultPort = "34872",
}) })

View File

@@ -1,139 +0,0 @@
local Config = require(script.Parent.Config)
local Environment = {
User = "User",
Dev = "Dev",
Test = "Test",
}
local DEFAULT_ENVIRONMENT = Config.isDevBuild and Environment.Dev or Environment.User
local VALUES = {
LogLevel = {
type = "IntValue",
values = {
[Environment.User] = 2,
[Environment.Dev] = 4,
[Environment.Test] = 4,
},
},
TypecheckingEnabled = {
type = "BoolValue",
values = {
[Environment.User] = false,
[Environment.Dev] = true,
[Environment.Test] = true,
},
},
}
local CONTAINER_NAME = "RojoDevSettings" .. Config.codename
local function getValueContainer()
return game:FindFirstChild(CONTAINER_NAME)
end
local valueContainer = getValueContainer()
game.ChildAdded:Connect(function(child)
local success, name = pcall(function()
return child.Name
end)
if success and name == CONTAINER_NAME then
valueContainer = child
end
end)
local function getStoredValue(name)
if valueContainer == nil then
return nil
end
local valueObject = valueContainer:FindFirstChild(name)
if valueObject == nil then
return nil
end
return valueObject.Value
end
local function setStoredValue(name, kind, value)
local object = valueContainer:FindFirstChild(name)
if object == nil then
object = Instance.new(kind)
object.Name = name
object.Parent = valueContainer
end
object.Value = value
end
local function createAllValues(environment)
assert(Environment[environment] ~= nil, "Invalid environment")
valueContainer = getValueContainer()
if valueContainer == nil then
valueContainer = Instance.new("Folder")
valueContainer.Name = CONTAINER_NAME
valueContainer.Parent = game
end
for name, value in pairs(VALUES) do
setStoredValue(name, value.type, value.values[environment])
end
end
local function getValue(name)
assert(VALUES[name] ~= nil, "Invalid DevSettings name")
local stored = getStoredValue(name)
if stored ~= nil then
return stored
end
return VALUES[name].values[DEFAULT_ENVIRONMENT]
end
local DevSettings = {}
function DevSettings:createDevSettings()
createAllValues(Environment.Dev)
end
function DevSettings:createTestSettings()
createAllValues(Environment.Test)
end
function DevSettings:hasChangedValues()
return valueContainer ~= nil
end
function DevSettings:resetValues()
if valueContainer then
valueContainer:Destroy()
valueContainer = nil
end
end
function DevSettings:isEnabled()
return valueContainer ~= nil
end
function DevSettings:getLogLevel()
return getValue("LogLevel")
end
function DevSettings:shouldTypecheck()
return getValue("TypecheckingEnabled")
end
function _G.ROJO_DEV_CREATE()
DevSettings:createDevSettings()
end
return DevSettings

View File

@@ -1,6 +1,7 @@
local RunService = game:GetService("RunService") local RunService = game:GetService("RunService")
local Log = require(script.Parent.Parent.Log) local Packages = script.Parent.Parent.Packages
local Log = require(Packages.Log)
--[[ --[[
A bidirectional map between instance IDs and Roblox instances. It lets us A bidirectional map between instance IDs and Roblox instances. It lets us

View File

@@ -3,10 +3,45 @@
patch returned from the API. patch returned from the API.
]] ]]
local t = require(script.Parent.Parent.t) local Packages = script.Parent.Parent.Packages
local t = require(Packages.t)
local Types = require(script.Parent.Types) local Types = require(script.Parent.Types)
local function deepEqual(a: any, b: any): boolean
local typeA = typeof(a)
if typeA ~= typeof(b) then
return false
end
if typeof(a) == "table" then
local checkedKeys = {}
for key, value in a do
checkedKeys[key] = true
if deepEqual(value, b[key]) == false then
return false
end
end
for key, value in b do
if checkedKeys[key] then continue end
if deepEqual(value, a[key]) == false then
return false
end
end
return true
end
if a == b then
return true
end
return false
end
local PatchSet = {} local PatchSet = {}
PatchSet.validate = t.interface({ PatchSet.validate = t.interface({
@@ -56,6 +91,32 @@ function PatchSet.hasUpdates(patchSet)
return next(patchSet.updated) ~= nil return next(patchSet.updated) ~= nil
end end
--[[
Tells whether the given PatchSets are equal.
]]
function PatchSet.isEqual(patchA, patchB)
return deepEqual(patchA, patchB)
end
--[[
Count the number of changes in the given PatchSet.
]]
function PatchSet.countChanges(patch)
local count = 0
for _ in patch.added do
count += 1
end
for _ in patch.removed do
count += 1
end
for _ in patch.updated do
count += 1
end
return count
end
--[[ --[[
Merge multiple PatchSet objects into the given PatchSet. Merge multiple PatchSet objects into the given PatchSet.
]] ]]
@@ -181,4 +242,4 @@ function PatchSet.humanSummary(instanceMap, patchSet)
return table.concat(statements, "\n") return table.concat(statements, "\n")
end end
return PatchSet return PatchSet

View File

@@ -2,7 +2,8 @@
Defines the errors that can be returned by the reconciler. Defines the errors that can be returned by the reconciler.
]] ]]
local Fmt = require(script.Parent.Parent.Parent.Fmt) local Packages = script.Parent.Parent.Parent.Packages
local Fmt = require(Packages.Fmt)
local Error = {} local Error = {}
@@ -34,4 +35,4 @@ function Error:__tostring()
return Fmt.fmt("Error({}): {:#?}", self.kind, self.details) return Fmt.fmt("Error({}): {:#?}", self.kind, self.details)
end end
return Error return Error

View File

@@ -5,7 +5,8 @@
Patches can come from the server or be generated by the client. Patches can come from the server or be generated by the client.
]] ]]
local Log = require(script.Parent.Parent.Parent.Log) local Packages = script.Parent.Parent.Parent.Packages
local Log = require(Packages.Log)
local PatchSet = require(script.Parent.Parent.PatchSet) local PatchSet = require(script.Parent.Parent.PatchSet)
local Types = require(script.Parent.Parent.Types) local Types = require(script.Parent.Parent.Types)

View File

@@ -3,7 +3,8 @@
usable by Rojo's reconciler, potentially using RbxDom. usable by Rojo's reconciler, potentially using RbxDom.
]] ]]
local RbxDom = require(script.Parent.Parent.Parent.RbxDom) local Packages = script.Parent.Parent.Parent.Packages
local RbxDom = require(Packages.RbxDom)
local Error = require(script.Parent.Error) local Error = require(script.Parent.Error)
local function decodeValue(encodedValue, instanceMap) local function decodeValue(encodedValue, instanceMap)
@@ -38,4 +39,4 @@ local function decodeValue(encodedValue, instanceMap)
return true, decodedValue return true, decodedValue
end end
return decodeValue return decodeValue

View File

@@ -3,7 +3,9 @@
patch that can be later applied. patch that can be later applied.
]] ]]
local Log = require(script.Parent.Parent.Parent.Log) local Packages = script.Parent.Parent.Parent.Packages
local Log = require(Packages.Log)
local invariant = require(script.Parent.Parent.invariant) local invariant = require(script.Parent.Parent.invariant)
local getProperty = require(script.Parent.getProperty) local getProperty = require(script.Parent.getProperty)
local Error = require(script.Parent.Error) local Error = require(script.Parent.Error)
@@ -13,6 +15,86 @@ local function isEmpty(table)
return next(table) == nil return next(table) == nil
end end
local function fuzzyEq(a: number, b: number, epsilon: number): boolean
return math.abs(a - b) < epsilon
end
local function trueEquals(a, b): boolean
-- Exit early for simple equality values
if a == b then
return true
end
local typeA, typeB = typeof(a), typeof(b)
-- For tables, try recursive deep equality
if typeA == "table" and typeB == "table" then
local checkedKeys = {}
for key, value in pairs(a) do
checkedKeys[key] = true
if not trueEquals(value, b[key]) then
return false
end
end
for key, value in pairs(b) do
if checkedKeys[key] then continue end
if not trueEquals(value, a[key]) then
return false
end
end
return true
-- For numbers, compare with epsilon of 0.0001 to avoid floating point inequality
elseif typeA == "number" and typeB == "number" then
return fuzzyEq(a, b, 0.0001)
-- For EnumItem->number, compare the EnumItem's value
elseif typeA == "number" and typeB == "EnumItem" then
return a == b.Value
elseif typeA == "EnumItem" and typeB == "number" then
return a.Value == b
-- For Color3s, compare to RGB ints to avoid floating point inequality
elseif typeA == "Color3" and typeB == "Color3" then
local aR, aG, aB = math.floor(a.R * 255), math.floor(a.G * 255), math.floor(a.B * 255)
local bR, bG, bB = math.floor(b.R * 255), math.floor(b.G * 255), math.floor(b.B * 255)
return aR == bR and aG == bG and aB == bB
-- For CFrames, compare to components with epsilon of 0.0001 to avoid floating point inequality
elseif typeA == "CFrame" and typeB == "CFrame" then
local aComponents, bComponents = {a:GetComponents()}, {b:GetComponents()}
for i, aComponent in aComponents do
if not fuzzyEq(aComponent, bComponents[i], 0.0001) then
return false
end
end
return true
-- For Vector3s, compare to components with epsilon of 0.0001 to avoid floating point inequality
elseif typeA == "Vector3" and typeB == "Vector3" then
local aComponents, bComponents = {a.X, a.Y, a.Z}, {b.X, b.Y, b.Z}
for i, aComponent in aComponents do
if not fuzzyEq(aComponent, bComponents[i], 0.0001) then
return false
end
end
return true
-- For Vector2s, compare to components with epsilon of 0.0001 to avoid floating point inequality
elseif typeA == "Vector2" and typeB == "Vector2" then
local aComponents, bComponents = {a.X, a.Y}, {b.X, b.Y}
for i, aComponent in aComponents do
if not fuzzyEq(aComponent, bComponents[i], 0.0001) then
return false
end
end
return true
end
return false
end
local function shouldDeleteUnknownInstances(virtualInstance) local function shouldDeleteUnknownInstances(virtualInstance)
if virtualInstance.Metadata ~= nil then if virtualInstance.Metadata ~= nil then
return not virtualInstance.Metadata.ignoreUnknownInstances return not virtualInstance.Metadata.ignoreUnknownInstances
@@ -71,7 +153,8 @@ local function diff(instanceMap, virtualInstances, rootId)
local ok, decodedValue = decodeValue(virtualValue, instanceMap) local ok, decodedValue = decodeValue(virtualValue, instanceMap)
if ok then if ok then
if existingValue ~= decodedValue then if not trueEquals(existingValue, decodedValue) then
Log.debug("{}.{} changed from '{}' to '{}'", instance:GetFullName(), propertyName, existingValue, decodedValue)
changedProperties[propertyName] = virtualValue changedProperties[propertyName] = virtualValue
end end
else else
@@ -113,6 +196,16 @@ local function diff(instanceMap, virtualInstances, rootId)
local childId = instanceMap.fromInstances[childInstance] local childId = instanceMap.fromInstances[childInstance]
if childId == nil then if childId == nil then
-- pcall to avoid security permission errors
local success, skip = pcall(function()
-- We don't remove instances that aren't going to be saved anyway,
-- such as the Rojo session lock value.
return childInstance.Archivable == false
end)
if success and skip then
continue
end
-- This is an existing instance not present in the virtual DOM. -- This is an existing instance not present in the virtual DOM.
-- We can mark it for deletion unless the user has asked us not -- We can mark it for deletion unless the user has asked us not
-- to delete unknown stuff. -- to delete unknown stuff.
@@ -152,4 +245,4 @@ local function diff(instanceMap, virtualInstances, rootId)
return true, patch return true, patch
end end
return diff return diff

View File

@@ -1,5 +1,6 @@
return function() return function()
local Log = require(script.Parent.Parent.Parent.Log) local Packages = script.Parent.Parent.Parent.Packages
local Log = require(Packages.Log)
local InstanceMap = require(script.Parent.Parent.InstanceMap) local InstanceMap = require(script.Parent.Parent.InstanceMap)
local PatchSet = require(script.Parent.Parent.PatchSet) local PatchSet = require(script.Parent.Parent.PatchSet)
@@ -286,4 +287,4 @@ return function()
expect(size(patch.added)).to.equal(1) expect(size(patch.added)).to.equal(1)
expect(patch.added["CHILD"]).to.equal(virtualInstances["CHILD"]) expect(patch.added["CHILD"]).to.equal(virtualInstances["CHILD"])
end) end)
end end

View File

@@ -2,7 +2,8 @@
Attempts to read a property from the given instance. Attempts to read a property from the given instance.
]] ]]
local RbxDom = require(script.Parent.Parent.Parent.RbxDom) local Packages = script.Parent.Parent.Parent.Packages
local RbxDom = require(Packages.RbxDom)
local Error = require(script.Parent.Error) local Error = require(script.Parent.Error)
local function getProperty(instance, propertyName) local function getProperty(instance, propertyName)
@@ -56,4 +57,4 @@ local function getProperty(instance, propertyName)
return true, valueOrErr return true, valueOrErr
end end
return getProperty return getProperty

View File

@@ -2,8 +2,9 @@
Attempts to set a property on the given instance. Attempts to set a property on the given instance.
]] ]]
local RbxDom = require(script.Parent.Parent.Parent.RbxDom) local Packages = script.Parent.Parent.Parent.Packages
local Log = require(script.Parent.Parent.Parent.Log) local Log = require(Packages.Log)
local RbxDom = require(Packages.RbxDom)
local Error = require(script.Parent.Error) local Error = require(script.Parent.Error)
local function setProperty(instance, propertyName, value) local function setProperty(instance, propertyName, value)
@@ -45,4 +46,4 @@ local function setProperty(instance, propertyName, value)
return true return true
end end
return setProperty return setProperty

View File

@@ -1,11 +1,14 @@
local StudioService = game:GetService("StudioService") local StudioService = game:GetService("StudioService")
local RunService = game:GetService("RunService") local RunService = game:GetService("RunService")
local Log = require(script.Parent.Parent.Log) local Packages = script.Parent.Parent.Packages
local Fmt = require(script.Parent.Parent.Fmt) local Log = require(Packages.Log)
local t = require(script.Parent.Parent.t) local Fmt = require(Packages.Fmt)
local t = require(Packages.t)
local Promise = require(Packages.Promise)
local ChangeBatcher = require(script.Parent.ChangeBatcher) local ChangeBatcher = require(script.Parent.ChangeBatcher)
local encodePatchUpdate = require(script.Parent.ChangeBatcher.encodePatchUpdate)
local InstanceMap = require(script.Parent.InstanceMap) local InstanceMap = require(script.Parent.InstanceMap)
local PatchSet = require(script.Parent.PatchSet) local PatchSet = require(script.Parent.PatchSet)
local Reconciler = require(script.Parent.Reconciler) local Reconciler = require(script.Parent.Reconciler)
@@ -94,6 +97,7 @@ function ServeSession.new(options)
__instanceMap = instanceMap, __instanceMap = instanceMap,
__changeBatcher = changeBatcher, __changeBatcher = changeBatcher,
__statusChangedCallback = nil, __statusChangedCallback = nil,
__patchAppliedCallback = nil,
__connections = connections, __connections = connections,
} }
@@ -121,23 +125,32 @@ function ServeSession:onStatusChanged(callback)
self.__statusChangedCallback = callback self.__statusChangedCallback = callback
end end
function ServeSession:setConfirmCallback(callback)
self.__userConfirmCallback = callback
end
function ServeSession:onPatchApplied(callback)
self.__patchAppliedCallback = callback
end
function ServeSession:start() function ServeSession:start()
self:__setStatus(Status.Connecting) self:__setStatus(Status.Connecting)
self.__apiContext:connect() self.__apiContext:connect()
:andThen(function(serverInfo) :andThen(function(serverInfo)
self:__setStatus(Status.Connected, serverInfo.projectName)
self:__applyGameAndPlaceId(serverInfo) self:__applyGameAndPlaceId(serverInfo)
local rootInstanceId = serverInfo.rootInstanceId return self:__initialSync(serverInfo)
return self:__initialSync(rootInstanceId)
:andThen(function() :andThen(function()
self:__setStatus(Status.Connected, serverInfo.projectName)
return self:__mainSyncLoop() return self:__mainSyncLoop()
end) end)
end) end)
:catch(function(err) :catch(function(err)
self:__stopInternal(err) if self.__status ~= Status.Disconnected then
self:__stopInternal(err)
end
end) end)
end end
@@ -194,8 +207,8 @@ function ServeSession:__onActiveScriptChanged(activeScript)
self.__apiContext:open(scriptId) self.__apiContext:open(scriptId)
end end
function ServeSession:__initialSync(rootInstanceId) function ServeSession:__initialSync(serverInfo)
return self.__apiContext:read({ rootInstanceId }) return self.__apiContext:read({ serverInfo.rootInstanceId })
:andThen(function(readResponseBody) :andThen(function(readResponseBody)
-- Tell the API Context that we're up-to-date with the version of -- Tell the API Context that we're up-to-date with the version of
-- the tree defined in this response. -- the tree defined in this response.
@@ -204,14 +217,14 @@ function ServeSession:__initialSync(rootInstanceId)
-- For any instances that line up with the Rojo server's view, start -- For any instances that line up with the Rojo server's view, start
-- tracking them in the reconciler. -- tracking them in the reconciler.
Log.trace("Matching existing Roblox instances to Rojo IDs") Log.trace("Matching existing Roblox instances to Rojo IDs")
self.__reconciler:hydrate(readResponseBody.instances, rootInstanceId, game) self.__reconciler:hydrate(readResponseBody.instances, serverInfo.rootInstanceId, game)
-- Calculate the initial patch to apply to the DataModel to catch us -- Calculate the initial patch to apply to the DataModel to catch us
-- up to what Rojo thinks the place should look like. -- up to what Rojo thinks the place should look like.
Log.trace("Computing changes that plugin needs to make to catch up to server...") Log.trace("Computing changes that plugin needs to make to catch up to server...")
local success, catchUpPatch = self.__reconciler:diff( local success, catchUpPatch = self.__reconciler:diff(
readResponseBody.instances, readResponseBody.instances,
rootInstanceId, serverInfo.rootInstanceId,
game game
) )
@@ -221,15 +234,50 @@ function ServeSession:__initialSync(rootInstanceId)
Log.trace("Computed hydration patch: {:#?}", debugPatch(catchUpPatch)) Log.trace("Computed hydration patch: {:#?}", debugPatch(catchUpPatch))
-- TODO: Prompt user to notify them of this patch, since it's local userDecision = "Accept"
-- effectively a conflict between the Rojo server and the client. In if self.__userConfirmCallback ~= nil then
-- the future, we'll ask which changes the user wants to keep. userDecision = self.__userConfirmCallback(self.__instanceMap, catchUpPatch, serverInfo)
end
local unappliedPatch = self.__reconciler:applyPatch(catchUpPatch) if userDecision == "Abort" then
return Promise.reject("Aborted Rojo sync operation")
if not PatchSet.isEmpty(unappliedPatch) then elseif userDecision == "Reject" and self.__twoWaySync then
Log.warn("Could not apply all changes requested by the Rojo server:\n{}", -- The user wants their studio DOM to write back to their Rojo DOM
PatchSet.humanSummary(self.__instanceMap, unappliedPatch)) -- so we will reverse the patch and send it back
local inversePatch = PatchSet.newEmpty()
-- Send back the current properties
for _, change in catchUpPatch.updated do
local instance = self.__instanceMap.fromIds[change.id]
if not instance then continue end
local update = encodePatchUpdate(instance, change.id, change.changedProperties)
table.insert(inversePatch.updated, update)
end
-- Add the removed instances back to Rojo
-- selene:allow(empty_if, unused_variable)
for _, instance in catchUpPatch.removed do
-- TODO: Generate ID for our instance and add it to inversePatch.added
end
-- Remove the additions we've rejected
for id, _change in catchUpPatch.added do
table.insert(inversePatch.removed, id)
end
self.__apiContext:write(inversePatch)
elseif userDecision == "Accept" then
local unappliedPatch = self.__reconciler:applyPatch(catchUpPatch)
if not PatchSet.isEmpty(unappliedPatch) then
Log.warn("Could not apply all changes requested by the Rojo server:\n{}",
PatchSet.humanSummary(self.__instanceMap, unappliedPatch))
end
if self.__patchAppliedCallback then
pcall(self.__patchAppliedCallback, catchUpPatch, unappliedPatch)
end
end end
end) end)
end end
@@ -244,6 +292,10 @@ function ServeSession:__mainSyncLoop()
Log.warn("Could not apply all changes requested by the Rojo server:\n{}", Log.warn("Could not apply all changes requested by the Rojo server:\n{}",
PatchSet.humanSummary(self.__instanceMap, unappliedPatch)) PatchSet.humanSummary(self.__instanceMap, unappliedPatch))
end end
if self.__patchAppliedCallback then
pcall(self.__patchAppliedCallback, message, unappliedPatch)
end
end end
if self.__status ~= Status.Disconnected then if self.__status ~= Status.Disconnected then

104
plugin/src/Settings.lua Normal file
View File

@@ -0,0 +1,104 @@
--[[
Persistent plugin settings.
]]
local plugin = plugin or script:FindFirstAncestorWhichIsA("Plugin")
local Rojo = script:FindFirstAncestor("Rojo")
local Packages = Rojo.Packages
local Log = require(Packages.Log)
local Roact = require(Packages.Roact)
local defaultSettings = {
openScriptsExternally = false,
twoWaySync = false,
showNotifications = true,
playSounds = true,
typecheckingEnabled = false,
logLevel = "Info",
priorEndpoints = {},
}
local Settings = {}
Settings._values = table.clone(defaultSettings)
Settings._updateListeners = {}
Settings._bindings = {}
if plugin then
for name, defaultValue in pairs(Settings._values) do
local savedValue = plugin:GetSetting("Rojo_" .. name)
if savedValue == nil then
-- plugin:SetSetting hits disc instead of memory, so it can be slow. Spawn so we don't hang.
task.spawn(plugin.SetSetting, plugin, "Rojo_" .. name, defaultValue)
Settings._values[name] = defaultValue
else
Settings._values[name] = savedValue
end
end
Log.trace("Loaded settings from plugin store")
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._values[name] = value
if self._bindings[name] then
self._bindings[name].set(value)
end
if plugin then
-- plugin:SetSetting hits disc instead of memory, so it can be slow. Spawn so we don't hang.
task.spawn(plugin.SetSetting, plugin, "Rojo_" .. name, value)
end
if self._updateListeners[name] then
for callback in pairs(self._updateListeners[name]) do
task.spawn(callback, value)
end
end
Log.trace(string.format("Set setting '%s' to '%s'", name, tostring(value)))
end
function Settings:onChanged(name, callback)
local listeners = self._updateListeners[name]
if listeners == nil then
listeners = {}
self._updateListeners[name] = listeners
end
listeners[callback] = true
Log.trace(string.format("Added listener for setting '%s' changes", name))
return function()
listeners[callback] = nil
Log.trace(string.format("Removed listener for setting '%s' changes", name))
end
end
function Settings:getBinding(name)
local cached = self._bindings[name]
if cached then
return cached.bind
end
local bind, set = Roact.createBinding(self._values[name])
self._bindings[name] = {
bind = bind,
set = set,
}
Log.trace(string.format("Created binding for setting '%s'", name))
return bind
end
return Settings

View File

@@ -1,6 +1,6 @@
local t = require(script.Parent.Parent.t) local Packages = script.Parent.Parent.Packages
local t = require(Packages.t)
local DevSettings = require(script.Parent.DevSettings) local Settings = require(script.Parent.Settings)
local strict = require(script.Parent.strict) local strict = require(script.Parent.strict)
local RbxId = t.string local RbxId = t.string
@@ -66,7 +66,7 @@ local ApiError = t.interface({
local function ifEnabled(innerCheck) local function ifEnabled(innerCheck)
return function(...) return function(...)
if DevSettings:shouldTypecheck() then if Settings:get("typecheckingEnabled") then
return innerCheck(...) return innerCheck(...)
else else
return true return true

View File

@@ -2,19 +2,20 @@ if not plugin then
return return
end end
local Log = require(script.Parent.Log) local Rojo = script:FindFirstAncestor("Rojo")
local Packages = Rojo.Packages
local DevSettings = require(script.DevSettings) local Log = require(Packages.Log)
local Roact = require(Packages.Roact)
Log.setLogLevelThunk(function()
return DevSettings:getLogLevel()
end)
local Roact = require(script.Parent.Roact)
local Settings = require(script.Settings)
local Config = require(script.Config) local Config = require(script.Config)
local App = require(script.App) local App = require(script.App)
Log.setLogLevelThunk(function()
return Log.Level[Settings:get("logLevel")] or Log.Level.Info
end)
local app = Roact.createElement(App, { local app = Roact.createElement(App, {
plugin = plugin plugin = plugin
}) })

View File

@@ -2,7 +2,10 @@ return function()
it("should load all submodules", function() it("should load all submodules", function()
local function loadRecursive(container) local function loadRecursive(container)
if container:IsA("ModuleScript") and not container.Name:find("%.spec$") then if container:IsA("ModuleScript") and not container.Name:find("%.spec$") then
require(container) local success, err = pcall(require, container)
if not success then
error(string.format("Failed to load '%s': %s", container.Name, err))
end
end end
for _, child in ipairs(container:GetChildren()) do for _, child in ipairs(container:GetChildren()) do
@@ -12,4 +15,4 @@ return function()
loadRecursive(script.Parent) loadRecursive(script.Parent)
end) end)
end end

View File

@@ -1,4 +1,6 @@
local Fmt = require(script.Parent.Parent.Fmt)
local Packages = script.Parent.Parent.Packages
local Fmt = require(Packages.Fmt)
local Config = require(script.Parent.Config) local Config = require(script.Parent.Config)
@@ -26,4 +28,4 @@ else
end end
end end
return invariant return invariant

View File

@@ -1,22 +0,0 @@
-- Roblox decided that sounds only play in Edit mode when parented to a plugin widget, for some reason
local plugin = plugin or script:FindFirstAncestorWhichIsA("Plugin")
local widget = plugin:CreateDockWidgetPluginGui("Rojo_soundPlayer", DockWidgetPluginGuiInfo.new(
Enum.InitialDockState.Float,
false, true,
10, 10,
10, 10
))
widget.Name = "Rojo_soundPlayer"
widget.Title = "Rojo Sound Player"
return function(soundId)
local sound = Instance.new("Sound")
sound.SoundId = soundId
sound.Parent = widget
sound.Ended:Connect(function()
sound:Destroy()
end)
sound:Play()
end

View File

@@ -1,6 +1,7 @@
local ContentProvider = game:GetService("ContentProvider") local ContentProvider = game:GetService("ContentProvider")
local Log = require(script.Parent.Parent.Log) local Packages = script.Parent.Parent.Packages
local Log = require(Packages.Log)
local Assets = require(script.Parent.Assets) local Assets = require(script.Parent.Assets)
@@ -29,4 +30,4 @@ local function preloadAssets()
end)() end)()
end end
return preloadAssets return preloadAssets

View File

@@ -1,5 +1,6 @@
return function(TestEZ) return function(TestEZ)
local Rojo = script.Parent.Parent local Rojo = script.Parent.Parent
local Packages = Rojo.Packages
TestEZ.TestBootstrap:run({ Rojo.Plugin, Rojo.Http, Rojo.Log }) TestEZ.TestBootstrap:run({ Rojo.Plugin, Packages.Http, Packages.Log, Packages.RbxDom })
end end

View File

@@ -0,0 +1,38 @@
-- Sounds only play in Edit mode when parented to a plugin widget, for some reason
local plugin = plugin or script:FindFirstAncestorWhichIsA("Plugin")
local widget = nil
if plugin then
widget = plugin:CreateDockWidgetPluginGui("Rojo_soundPlayer", DockWidgetPluginGuiInfo.new(
Enum.InitialDockState.Float,
false, true,
10, 10,
10, 10
))
widget.Name = "Rojo_soundPlayer"
widget.Title = "Rojo Sound Player"
end
local SoundPlayer = {}
SoundPlayer.__index = SoundPlayer
function SoundPlayer.new(settings)
return setmetatable({
settings = settings,
}, SoundPlayer)
end
function SoundPlayer:play(soundId)
if self.settings and self.settings:get("playSounds") == false then return end
local sound = Instance.new("Sound")
sound.SoundId = soundId
sound.Parent = widget
sound.Ended:Connect(function()
sound:Destroy()
end)
sound:Play()
end
return SoundPlayer

View File

@@ -8,8 +8,8 @@
"$path": "default.project.json" "$path": "default.project.json"
}, },
"TestEZ": { "Packages": {
"$path": "modules/testez" "$path": "DevPackages"
} }
}, },

33
plugin/wally.lock Normal file
View File

@@ -0,0 +1,33 @@
# This file is automatically @generated by Wally.
# It is not intended for manual editing.
registry = "test"
[[package]]
name = "evaera/promise"
version = "4.0.0"
dependencies = []
[[package]]
name = "osyrisrblx/t"
version = "3.0.0"
dependencies = []
[[package]]
name = "reselim/flipper"
version = "2.0.0"
dependencies = []
[[package]]
name = "roblox/roact"
version = "1.4.4"
dependencies = []
[[package]]
name = "roblox/testez"
version = "0.4.1"
dependencies = []
[[package]]
name = "rojo-rbx/rojo"
version = "7.2.1"
dependencies = [["Flipper", "reselim/flipper@2.0.0"], ["Promise", "evaera/promise@4.0.0"], ["Roact", "roblox/roact@1.4.4"], ["t", "osyrisrblx/t@3.0.0"]]

17
plugin/wally.toml Normal file
View File

@@ -0,0 +1,17 @@
[package]
name = "rojo-rbx/rojo"
description = "Rojo enables Roblox developers to use professional-grade software engineering tools"
version = "7.2.1"
license = "MPL-2.0"
authors = ["LPGhatguy (https://lpg.space/)"]
registry = "https://github.com/upliftgames/wally-index"
realm = "shared"
[dependencies]
Flipper = "reselim/flipper@2.0.0"
Promise = "evaera/promise@4.0.0"
Roact = "roblox/roact@1.4.4"
t = "osyrisrblx/t@3.0.0"
[dev-dependencies]
TestEZ = "roblox/testez@0.4.1"

2
plugin/watch-build.sh Normal file
View File

@@ -0,0 +1,2 @@
# Continously build the rojo plugin into the local plugin directory on Windows
rojo build plugin/default.project.json -o $LOCALAPPDATA/Roblox/Plugins/Rojo.rbxm --watch

View File

@@ -1,6 +1,5 @@
--- ---
source: tests/tests/build.rs source: tests/tests/build.rs
assertion_line: 99
expression: contents expression: contents
--- ---
<roblox version="4"> <roblox version="4">
@@ -11,12 +10,24 @@ expression: contents
<Item class="Folder" referent="1"> <Item class="Folder" referent="1">
<Properties> <Properties>
<string name="Name">Explicit</string> <string name="Name">Explicit</string>
<BinaryString name="AttributesSerialize">AgAAAAUAAABIZWxsbwIFAAAAV29ybGQGAAAAVmVjdG9yEQAAgD8AAABAAABAQA==</BinaryString> <BinaryString name="AttributesSerialize">DQAAAAQAAABCb29sAwEKAAAAQnJpY2tDb2xvcg4BAAAABgAAAENvbG9yMw8AAAAAAAAAAAAAAAANAAAAQ29sb3JTZXF1ZW5jZRkCAAAAAAAAAAAAAAAAAIA/AACAPwAAgD8AAAAAAACAPwAAgD8AAIA/AACAPwcAAABGbG9hdDMyBQAAAAAHAAAARmxvYXQ2NAYAAAAAAAAAAAsAAABOdW1iZXJSYW5nZRsAAAAAAAAAAA4AAABOdW1iZXJTZXF1ZW5jZRcCAAAAAAAAAAAAAAAAAAAAAAAAAAAAgD8AAAAABAAAAFJlY3QcAAAAAAAAAAAAAAAAAAAAAAQAAABVRGltCQAAAAAAAAAABQAAAFVEaW0yCgAAAAAAAAAAAAAAAAAAAAAHAAAAVmVjdG9yMhAAAAAAAAAAAAcAAABWZWN0b3IzEQAAAAAAAAAAAAAAAA==</BinaryString>
</Properties> </Properties>
</Item> </Item>
<Item class="Folder" referent="2"> <Item class="Folder" referent="2">
<Properties> <Properties>
<string name="Name">ImplicitAttributes</string> <string name="Name">Implicit</string>
<BinaryString name="AttributesSerialize">AwAAAAQAAABCb29sAwEGAAAATnVtYmVyBgAAAAAAAOA/BgAAAFN0cmluZwIEAAAAVGVzdA==</BinaryString>
</Properties>
</Item>
<Item class="Folder" referent="3">
<Properties>
<string name="Name">LegacyExplicit</string>
<BinaryString name="AttributesSerialize">AgAAAAUAAABIZWxsbwIFAAAAV29ybGQGAAAAVmVjdG9yEQAAgD8AAABAAABAQA==</BinaryString>
</Properties>
</Item>
<Item class="Folder" referent="4">
<Properties>
<string name="Name">LegacyImplicit</string>
<BinaryString name="AttributesSerialize">AgAAAAMAAABIZXkCBwAAAEdyYW5kbWEGAAAAVmVjdG9yEQAAgEAAAKBAAADAQA==</BinaryString> <BinaryString name="AttributesSerialize">AgAAAAMAAABIZXkCBwAAAEdyYW5kbWEGAAAAVmVjdG9yEQAAgEAAAKBAAADAQA==</BinaryString>
</Properties> </Properties>
</Item> </Item>

View File

@@ -0,0 +1,19 @@
---
source: tests/tests/build.rs
assertion_line: 100
expression: contents
---
<roblox version="4">
<Item class="LocalizationTable" referent="0">
<Properties>
<string name="Name">init_csv_with_children</string>
<string name="Contents">[{"key":"init.csv","values":{}}]</string>
</Properties>
<Item class="LocalizationTable" referent="1">
<Properties>
<string name="Name">other</string>
<string name="Contents">[{"key":"other.csv","values":{}}]</string>
</Properties>
</Item>
</Item>
</roblox>

View File

@@ -3,7 +3,85 @@
"tree": { "tree": {
"$className": "Folder", "$className": "Folder",
"Implicit": {
"$className": "Folder",
"$attributes": {
"Bool": true,
"Number": 0.5,
"String": "Test"
}
},
"Explicit": { "Explicit": {
"$className": "Folder",
"$attributes": {
"Bool": {
"Bool": true
},
"Float32": {
"Float32": 0
},
"Float64": {
"Float64": 0
},
"UDim": {
"UDim": [0, 0]
},
"UDim2": {
"UDim2": [[0, 0], [0, 0]]
},
"BrickColor": {
"BrickColor": 1
},
"Color3": {
"Color3": [0, 0, 0]
},
"Vector2": {
"Vector2": [0, 0]
},
"Vector3": {
"Vector3": [0, 0, 0]
},
"NumberSequence": {
"NumberSequence": {
"keypoints": [
{
"time": 0,
"value": 0,
"envelope": 0
},
{
"time": 1,
"value": 0,
"envelope": 0
}
]
}
},
"ColorSequence": {
"ColorSequence": {
"keypoints": [
{
"time": 0,
"color": [1, 1, 1]
},
{
"time": 1,
"color": [1, 1, 1]
}
]
}
},
"NumberRange": {
"NumberRange": [0, 0]
},
"Rect": {
"Rect": [[0, 0], [0, 0]]
}
}
},
"LegacyExplicit": {
"$className": "Folder", "$className": "Folder",
"$properties": { "$properties": {
"Attributes": { "Attributes": {
@@ -19,7 +97,7 @@
} }
}, },
"ImplicitAttributes": { "LegacyImplicit": {
"$className": "Folder", "$className": "Folder",
"$properties": { "$properties": {
"Attributes": { "Attributes": {

View File

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

View File

@@ -0,0 +1,2 @@
Key
init.csv
1 Key
2 init.csv

View File

@@ -0,0 +1,2 @@
Key
other.csv
1 Key
2 other.csv

View File

@@ -1,5 +1,6 @@
use std::{ use std::{
fs, fs,
path::PathBuf,
sync::{Arc, Mutex}, sync::{Arc, Mutex},
}; };
@@ -124,44 +125,20 @@ impl JobThreadContext {
// 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
// of the tree. Calculate and apply all of these changes. // of the tree. Calculate and apply all of these changes.
let applied_patches = { let applied_patches = match event {
let mut tree = self.tree.lock().unwrap(); VfsEvent::Write(path) => {
let mut applied_patches = Vec::new(); if path.is_dir() {
return;
match event {
VfsEvent::Create(path) | VfsEvent::Write(path) | VfsEvent::Remove(path) => {
// Find the nearest ancestor to this path that has
// associated instances in the tree. This helps make sure
// that we handle additions correctly, especially if we
// receive events for descendants of a large tree being
// created all at once.
let mut current_path = path.as_path();
let affected_ids = loop {
let ids = tree.get_ids_at_path(&current_path);
log::trace!("Path {} affects IDs {:?}", current_path.display(), ids);
if !ids.is_empty() {
break ids.to_vec();
}
log::trace!("Trying parent path...");
match current_path.parent() {
Some(parent) => current_path = parent,
None => break Vec::new(),
}
};
for id in affected_ids {
if let Some(patch) = compute_and_apply_changes(&mut tree, &self.vfs, id) {
applied_patches.push(patch);
}
}
} }
_ => log::warn!("Unhandled VFS event: {:?}", event), on_vfs_event(path, &self.tree, &self.vfs)
}
VfsEvent::Create(path) | VfsEvent::Remove(path) => {
on_vfs_event(path, &self.tree, &self.vfs)
}
_ => {
log::warn!("Unhandled VFS event: {:?}", event);
Vec::new()
} }
applied_patches
}; };
// Notify anyone listening to the message queue about the changes we // Notify anyone listening to the message queue about the changes we
@@ -253,10 +230,51 @@ impl JobThreadContext {
apply_patch_set(&mut tree, patch_set) apply_patch_set(&mut tree, patch_set)
}; };
self.message_queue.push_messages(&[applied_patch]); if !applied_patch.is_empty() {
self.message_queue.push_messages(&[applied_patch]);
}
} }
} }
// Find the nearest ancestor to this path that has
// associated instances in the tree. This helps make sure
// that we handle additions correctly, especially if we
// receive events for descendants of a large tree being
// created all at once.
fn on_vfs_event(
path: PathBuf,
tree: &Arc<Mutex<RojoTree>>,
vfs: &Arc<Vfs>,
) -> Vec<AppliedPatchSet> {
let mut tree = tree.lock().unwrap();
let mut applied_patches = Vec::new();
let mut current_path = path.as_path();
let affected_ids = loop {
let ids = tree.get_ids_at_path(&current_path);
log::trace!("Path {} affects IDs {:?}", current_path.display(), ids);
if !ids.is_empty() {
break ids.to_vec();
}
log::trace!("Trying parent path...");
match current_path.parent() {
Some(parent) => current_path = parent,
None => break Vec::new(),
}
};
for id in affected_ids {
if let Some(patch) = compute_and_apply_changes(&mut tree, &vfs, id) {
if !patch.is_empty() {
applied_patches.push(patch);
}
}
}
applied_patches
}
fn compute_and_apply_changes(tree: &mut RojoTree, vfs: &Vfs, id: Ref) -> Option<AppliedPatchSet> { fn compute_and_apply_changes(tree: &mut RojoTree, vfs: &Vfs, id: Ref) -> Option<AppliedPatchSet> {
let metadata = tree let metadata = tree
.get_metadata(id) .get_metadata(id)

View File

@@ -5,6 +5,8 @@ use clap::Parser;
use crate::project::Project; use crate::project::Project;
use super::resolve_path;
/// Reformat a Rojo project using the standard JSON formatting rules. /// Reformat a Rojo project using the standard JSON formatting rules.
#[derive(Debug, Parser)] #[derive(Debug, Parser)]
pub struct FmtProjectCommand { pub struct FmtProjectCommand {
@@ -15,7 +17,8 @@ pub struct FmtProjectCommand {
impl FmtProjectCommand { impl FmtProjectCommand {
pub fn run(self) -> anyhow::Result<()> { pub fn run(self) -> anyhow::Result<()> {
let project = Project::load_fuzzy(&self.project)? let base_path = resolve_path(&self.project);
let project = Project::load_fuzzy(&base_path)?
.context("A project file is required to run 'rojo fmt-project'")?; .context("A project file is required to run 'rojo fmt-project'")?;
let serialized = serde_json::to_string_pretty(&project) let serialized = serde_json::to_string_pretty(&project)

Some files were not shown because too many files have changed in this diff Show More