forked from rojo-rbx/rojo
Compare commits
91 Commits
v7.2.1-rel
...
v7.4.0-rc3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
41994ec82e | ||
|
|
cd14ea7c62 | ||
|
|
9f13bca6b8 | ||
|
|
f4252c3e97 | ||
|
|
6598867d3d | ||
|
|
f39e040a0d | ||
|
|
a3d140269b | ||
|
|
feac29ea40 | ||
|
|
834c8cdbca | ||
|
|
d441fbdf91 | ||
|
|
e897f524dc | ||
|
|
1caf9446d8 | ||
|
|
bfd2c885db | ||
|
|
f467fa4e59 | ||
|
|
41fca4a2bb | ||
|
|
d38f955144 | ||
|
|
010e50a25d | ||
|
|
eab7c607cd | ||
|
|
3cafbf7f1a | ||
|
|
d7277b5a5b | ||
|
|
bb8dd1402d | ||
|
|
539cd0d418 | ||
|
|
0f8e1625d5 | ||
|
|
840e9bedb2 | ||
|
|
e11ad476fc | ||
|
|
c43726bc75 | ||
|
|
c9ab933a23 | ||
|
|
066a0b1668 | ||
|
|
aa68fe412e | ||
|
|
d748ea7e40 | ||
|
|
a7a282078f | ||
|
|
2fad3b588a | ||
|
|
4cb5d4a9c5 | ||
|
|
5b22ef192e | ||
|
|
34024d8524 | ||
|
|
ecc31dea15 | ||
|
|
d0e48d9bdc | ||
|
|
f6fc5599c0 | ||
|
|
89b6666436 | ||
|
|
94d45a2262 | ||
|
|
dc17a185ca | ||
|
|
4915477823 | ||
|
|
8662d2227c | ||
|
|
dd01a9bef3 | ||
|
|
6e320b1fd5 | ||
|
|
6e40993199 | ||
|
|
9d48af2b50 | ||
|
|
28d48a76e3 | ||
|
|
80eb14f9da | ||
|
|
623fa06d52 | ||
|
|
7154113c13 | ||
|
|
0a932ff880 | ||
|
|
7ef4a1ff12 | ||
|
|
ccc52b69d2 | ||
|
|
8139fdc738 | ||
|
|
a4fd53d516 | ||
|
|
27357110b5 | ||
|
|
fde78738b6 | ||
|
|
ce530e795a | ||
|
|
658d211779 | ||
|
|
66c1cd0d93 | ||
|
|
55ac231cec | ||
|
|
67674d53a2 | ||
|
|
8646b2dfce | ||
|
|
a2f68c2e3c | ||
|
|
5b1a090c5e | ||
|
|
e9efa238b0 | ||
|
|
0dabd8a1f6 | ||
|
|
b7a1f82f56 | ||
|
|
2507e096b7 | ||
|
|
b303b0a99c | ||
|
|
342fb57d14 | ||
|
|
a9ca77e27f | ||
|
|
6542304340 | ||
|
|
6b0f7f94b6 | ||
|
|
d87c76a23e | ||
|
|
305423b856 | ||
|
|
4b62190aff | ||
|
|
e17771a6a5 | ||
|
|
bac30ae78b | ||
|
|
c0219922b2 | ||
|
|
b5ed952d5c | ||
|
|
7994bc4909 | ||
|
|
b88d34c639 | ||
|
|
96cb1ee3fd | ||
|
|
003abe86bb | ||
|
|
6ec411a618 | ||
|
|
c7c0903804 | ||
|
|
cdc972a5ce | ||
|
|
17de912608 | ||
|
|
9876508887 |
2
.git-blame-ignore-revs
Normal file
2
.git-blame-ignore-revs
Normal file
@@ -0,0 +1,2 @@
|
||||
# stylua formatting
|
||||
0f8e1625d572a5fe0f7b5c08653ff92cc837d346
|
||||
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
@@ -1 +0,0 @@
|
||||
patreon: lpghatguy
|
||||
23
.github/workflows/changelog.yml
vendored
Normal file
23
.github/workflows/changelog.yml
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
name: Changelog Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [assigned, opened, synchronize, reopened, labeled, unlabeled]
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Check Actions
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Changelog check
|
||||
uses: Zomzog/changelog-checker@v1.3.0
|
||||
with:
|
||||
fileName: CHANGELOG.md
|
||||
noChangelogLabel: skip changelog
|
||||
checkNotification: Simple
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
36
.github/workflows/ci.yml
vendored
36
.github/workflows/ci.yml
vendored
@@ -16,10 +16,12 @@ jobs:
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
rust_version: [stable, 1.58.1]
|
||||
rust_version: [stable, 1.70.0]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Install Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
@@ -29,17 +31,9 @@ jobs:
|
||||
profile: minimal
|
||||
|
||||
- name: Setup Aftman
|
||||
uses: ok-nick/setup-aftman@v0.1.0
|
||||
uses: ok-nick/setup-aftman@v0.3.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
trust-check: false
|
||||
version: 'v0.2.6'
|
||||
|
||||
- name: Install packages
|
||||
run: |
|
||||
cd plugin
|
||||
wally install
|
||||
cd ..
|
||||
version: 'v0.2.7'
|
||||
|
||||
- name: Build
|
||||
run: cargo build --locked --verbose
|
||||
@@ -48,11 +42,13 @@ jobs:
|
||||
run: cargo test --locked --verbose
|
||||
|
||||
lint:
|
||||
name: Rustfmt and Clippy
|
||||
name: Rustfmt, Clippy, & Stylua
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Install Rust
|
||||
uses: actions-rs/toolchain@v1
|
||||
@@ -62,20 +58,16 @@ jobs:
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Setup Aftman
|
||||
uses: ok-nick/setup-aftman@v0.1.0
|
||||
uses: ok-nick/setup-aftman@v0.3.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
trust-check: false
|
||||
version: 'v0.2.6'
|
||||
version: 'v0.2.7'
|
||||
|
||||
- name: Install packages
|
||||
run: |
|
||||
cd plugin
|
||||
wally install
|
||||
cd ..
|
||||
- name: Stylua
|
||||
run: stylua --check plugin/src
|
||||
|
||||
- name: Rustfmt
|
||||
run: cargo fmt -- --check
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy
|
||||
run: cargo clippy
|
||||
|
||||
|
||||
23
.github/workflows/release.yml
vendored
23
.github/workflows/release.yml
vendored
@@ -28,6 +28,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Setup Aftman
|
||||
uses: ok-nick/setup-aftman@v0.1.0
|
||||
@@ -36,12 +38,6 @@ jobs:
|
||||
trust-check: false
|
||||
version: 'v0.2.6'
|
||||
|
||||
- name: Install packages
|
||||
run: |
|
||||
cd plugin
|
||||
wally install
|
||||
cd ..
|
||||
|
||||
- name: Build Plugin
|
||||
run: rojo build plugin --output Rojo.rbxm
|
||||
|
||||
@@ -94,6 +90,8 @@ jobs:
|
||||
BIN: rojo
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: true
|
||||
|
||||
- name: Get Version from Tag
|
||||
shell: bash
|
||||
@@ -117,15 +115,8 @@ jobs:
|
||||
trust-check: false
|
||||
version: 'v0.2.6'
|
||||
|
||||
- name: Install packages
|
||||
run: |
|
||||
cd plugin
|
||||
wally install
|
||||
cd ..
|
||||
shell: bash
|
||||
|
||||
- name: Build Release
|
||||
run: cargo build --release --locked --verbose
|
||||
run: cargo build --release --locked --verbose --target ${{ matrix.target }}
|
||||
env:
|
||||
# Build into a known directory so we can find our build artifact more
|
||||
# easily.
|
||||
@@ -141,11 +132,11 @@ jobs:
|
||||
mkdir staging
|
||||
|
||||
if [ "${{ matrix.host }}" = "windows" ]; then
|
||||
cp "output/release/$BIN.exe" staging/
|
||||
cp "output/${{ matrix.target }}/release/$BIN.exe" staging/
|
||||
cd staging
|
||||
7z a ../release.zip *
|
||||
else
|
||||
cp "output/release/$BIN" staging/
|
||||
cp "output/${{ matrix.target }}/release/$BIN" staging/
|
||||
cd staging
|
||||
zip ../release.zip *
|
||||
fi
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -13,9 +13,6 @@
|
||||
# Test places for the Roblox Studio Plugin
|
||||
/plugin/*.rbxlx
|
||||
|
||||
# Packages for the Roblox Studio Plugin
|
||||
/plugin/*Packages
|
||||
|
||||
# Roblox Studio holds 'lock' files on places
|
||||
*.rbxl.lock
|
||||
*.rbxlx.lock
|
||||
|
||||
18
.gitmodules
vendored
Normal file
18
.gitmodules
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
[submodule "plugin/Packages/Roact"]
|
||||
path = plugin/Packages/Roact
|
||||
url = https://github.com/roblox/roact.git
|
||||
[submodule "plugin/Packages/Flipper"]
|
||||
path = plugin/Packages/Flipper
|
||||
url = https://github.com/reselim/flipper.git
|
||||
[submodule "plugin/Packages/Promise"]
|
||||
path = plugin/Packages/Promise
|
||||
url = https://github.com/evaera/roblox-lua-promise.git
|
||||
[submodule "plugin/Packages/t"]
|
||||
path = plugin/Packages/t
|
||||
url = https://github.com/osyrisrblx/t.git
|
||||
[submodule "plugin/Packages/TestEZ"]
|
||||
path = plugin/Packages/TestEZ
|
||||
url = https://github.com/roblox/testez.git
|
||||
[submodule "plugin/Packages/Highlighter"]
|
||||
path = plugin/Packages/Highlighter
|
||||
url = https://github.com/boatbomber/highlighter.git
|
||||
211
CHANGELOG.md
211
CHANGELOG.md
@@ -1,9 +1,216 @@
|
||||
# Rojo Changelog
|
||||
|
||||
## Unreleased Changes
|
||||
* Added `--watch` flag to `rojo sourcemap` ([#602])
|
||||
|
||||
## [7.4.0-rc3] - October 25, 2023
|
||||
* Changed `sourcemap --watch` to only generate the sourcemap when it's necessary ([#800])
|
||||
* Switched script source property getter and setter to `ScriptEditorService` methods ([#801])
|
||||
|
||||
This ensures that the script editor reflects any changes Rojo makes to a script while it is open in the script editor.
|
||||
|
||||
* Fixed issues when handling `SecurityCapabilities` values ([#803], [#807])
|
||||
* Fixed Rojo plugin erroring out when attempting to sync attributes with invalid names ([#809])
|
||||
|
||||
[#800]: https://github.com/rojo-rbx/rojo/pull/800
|
||||
[#801]: https://github.com/rojo-rbx/rojo/pull/801
|
||||
[#803]: https://github.com/rojo-rbx/rojo/pull/803
|
||||
[#807]: https://github.com/rojo-rbx/rojo/pull/807
|
||||
[#809]: https://github.com/rojo-rbx/rojo/pull/809
|
||||
|
||||
## [7.4.0-rc2] - October 3, 2023
|
||||
* Fixed bug with parsing version for plugin validation ([#797])
|
||||
|
||||
[#797]: https://github.com/rojo-rbx/rojo/pull/797
|
||||
|
||||
## [7.4.0-rc1] - October 3, 2023
|
||||
### Additions
|
||||
#### Project format
|
||||
* Added support for `.toml` files to `$path` ([#633])
|
||||
* Added support for `Font` and `CFrame` attributes ([rbx-dom#299], [rbx-dom#296])
|
||||
* Added the `emitLegacyScripts` field to the project format ([#765]). The behavior is outlined below:
|
||||
|
||||
| `emitLegacyScripts` Value | Action Taken by Rojo |
|
||||
|---------------------------|------------------------------------------------------------------------------------------------------------------|
|
||||
| false | Rojo emits Scripts with the appropriate `RunContext` for `*.client.lua` and `*.server.lua` files in the project. |
|
||||
| true (default) | Rojo emits LocalScripts and Scripts with legacy `RunContext` (same behavior as previously). |
|
||||
|
||||
|
||||
It can be used like this:
|
||||
```json
|
||||
{
|
||||
"emitLegacyScripts": false,
|
||||
"name": "MyCoolRunContextProject",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
* Added `Terrain` classname inference, similar to services ([#771])
|
||||
|
||||
`Terrain` may now be defined in projects without using `$className`:
|
||||
```json
|
||||
"Workspace": {
|
||||
"Terrain": {
|
||||
"$path": "path/to/terrain.rbxm"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
* Added support for `Terrain.MaterialColors` ([#770])
|
||||
|
||||
`Terrain.MaterialColors` is now represented in projects in a human readable format:
|
||||
```json
|
||||
"Workspace": {
|
||||
"Terrain": {
|
||||
"$path": "path/to/terrain.rbxm"
|
||||
"$properties": {
|
||||
"MaterialColors": {
|
||||
"Grass": [10, 20, 30],
|
||||
"Asphalt": [40, 50, 60],
|
||||
"LeafyGrass": [255, 155, 55]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
* Added better support for `Font` properties ([#731])
|
||||
|
||||
`FontFace` properties may now be defined using implicit property syntax:
|
||||
```json
|
||||
"TextBox": {
|
||||
"$className": "TextBox",
|
||||
"$properties": {
|
||||
"FontFace": {
|
||||
"family": "rbxasset://fonts/families/RobotoMono.json",
|
||||
"weight": "Thin",
|
||||
"style": "Normal"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Patch visualizer and notifications
|
||||
* Added a setting to control patch confirmation behavior ([#774])
|
||||
|
||||
This is a new setting for controlling when the Rojo plugin prompts for confirmation before syncing. It has four options:
|
||||
* Initial (default): prompts only once for a project in a given Studio session
|
||||
* Always: always prompts for confirmation
|
||||
* Large Changes: only prompts when there are more than X changed instances. The number of instances is configurable - an additional setting for the number of instances becomes available when this option is chosen
|
||||
* Unlisted PlaceId: only prompts if the place ID is not present in servePlaceIds
|
||||
|
||||
* Added the ability to select Instances in patch visualizer ([#709])
|
||||
|
||||
Double-clicking an instance in the patch visualizer sets Roblox Studio's selection to the instance.
|
||||
|
||||
* Added a sync reminder notification. ([#689])
|
||||
|
||||
Rojo detects if you have previously synced to a place, and displays a notification reminding you to sync again:
|
||||
|
||||

|
||||
|
||||
* Added rich Source diffs in patch visualizer ([#748])
|
||||
|
||||
A "View Diff" button for script sources is now present in the patch visualizer. Clicking it displays a side-by-side diff of the script changes:
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
* Patch visualizer now indicates what changes failed to apply. ([#717])
|
||||
|
||||
A clickable warning label is displayed when the Rojo plugin is unable to apply changes. Clicking the label displays precise information about which changes failed:
|
||||
|
||||

|
||||
|
||||
|
||||
#### Miscellaneous
|
||||
* Added `plugin` flag to the `build` command that outputs to the local plugins folder ([#735])
|
||||
|
||||
This is a flag that builds a Rojo project into Roblox Studio's plugins directory. This allows you to build a Rojo project and load it into Studio as a plugin without having to type the full path to the plugins directory. It can be used like this: `rojo build <PATH-TO-PROJECT> --plugin <FILE-NAME>`
|
||||
|
||||
* Added new plugin template to the `init` command ([#738])
|
||||
|
||||
This is a new template geared towards plugins. It is similar to the model template, but creates a `Script` instead of a `ModuleScript` in the `src` directory. It can be used like this: `rojo init --kind plugin`
|
||||
|
||||
* Added protection against syncing non-place projects as a place. ([#691])
|
||||
* Add buttons for navigation on the Connected page ([#722])
|
||||
|
||||
### Fixes
|
||||
* Significantly improved performance of `rojo serve` and `rojo build` on macOS. [#783]
|
||||
* Significantly improved performance of `rojo sourcemap` ([#668])
|
||||
* Fixed the diff visualizer of connected sessions. ([#674])
|
||||
* Fixed disconnected session activity. ([#675])
|
||||
* Skip confirming patches that contain only a datamodel name change. ([#688])
|
||||
* Fix Rojo breaking when users undo/redo in Studio ([#708])
|
||||
* Improve tooltip behavior ([#723])
|
||||
* Better settings controls ([#725])
|
||||
* Rework patch visualizer with many fixes and improvements ([#713], [#726], [#755])
|
||||
|
||||
[#668]: https://github.com/rojo-rbx/rojo/pull/668
|
||||
[#674]: https://github.com/rojo-rbx/rojo/pull/674
|
||||
[#675]: https://github.com/rojo-rbx/rojo/pull/675
|
||||
[#688]: https://github.com/rojo-rbx/rojo/pull/688
|
||||
[#689]: https://github.com/rojo-rbx/rojo/pull/689
|
||||
[#691]: https://github.com/rojo-rbx/rojo/pull/691
|
||||
[#709]: https://github.com/rojo-rbx/rojo/pull/709
|
||||
[#708]: https://github.com/rojo-rbx/rojo/pull/708
|
||||
[#713]: https://github.com/rojo-rbx/rojo/pull/713
|
||||
[#717]: https://github.com/rojo-rbx/rojo/pull/717
|
||||
[#722]: https://github.com/rojo-rbx/rojo/pull/722
|
||||
[#723]: https://github.com/rojo-rbx/rojo/pull/723
|
||||
[#725]: https://github.com/rojo-rbx/rojo/pull/725
|
||||
[#726]: https://github.com/rojo-rbx/rojo/pull/726
|
||||
[#633]: https://github.com/rojo-rbx/rojo/pull/633
|
||||
[#735]: https://github.com/rojo-rbx/rojo/pull/735
|
||||
[#731]: https://github.com/rojo-rbx/rojo/pull/731
|
||||
[#738]: https://github.com/rojo-rbx/rojo/pull/738
|
||||
[#748]: https://github.com/rojo-rbx/rojo/pull/748
|
||||
[#755]: https://github.com/rojo-rbx/rojo/pull/755
|
||||
[#765]: https://github.com/rojo-rbx/rojo/pull/765
|
||||
[#770]: https://github.com/rojo-rbx/rojo/pull/770
|
||||
[#771]: https://github.com/rojo-rbx/rojo/pull/771
|
||||
[#774]: https://github.com/rojo-rbx/rojo/pull/774
|
||||
[#783]: https://github.com/rojo-rbx/rojo/pull/783
|
||||
[rbx-dom#299]: https://github.com/rojo-rbx/rbx-dom/pull/299
|
||||
[rbx-dom#296]: https://github.com/rojo-rbx/rbx-dom/pull/296
|
||||
|
||||
## [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])
|
||||
@@ -533,4 +740,4 @@ This is a general maintenance release for the Rojo 0.5.x release series.
|
||||
* More robust syncing with a new reconciler
|
||||
|
||||
## [0.1.0](https://github.com/rojo-rbx/rojo/releases/tag/v0.1.0) (November 29, 2017)
|
||||
* Initial release, functionally very similar to [rbxfs](https://github.com/LPGhatguy/rbxfs)
|
||||
* Initial release, functionally very similar to [rbxfs](https://github.com/LPGhatguy/rbxfs)
|
||||
|
||||
1405
Cargo.lock
generated
1405
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
30
Cargo.toml
30
Cargo.toml
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "rojo"
|
||||
version = "7.2.1"
|
||||
rust-version = "1.58.1"
|
||||
version = "7.4.0-rc3"
|
||||
rust-version = "1.70.0"
|
||||
authors = ["Lucien Greathouse <me@lpghatguy.com>"]
|
||||
description = "Enables professional-grade development tools for Roblox developers"
|
||||
license = "MPL-2.0"
|
||||
@@ -12,9 +12,7 @@ readme = "README.md"
|
||||
edition = "2021"
|
||||
build = "build.rs"
|
||||
|
||||
exclude = [
|
||||
"/test-projects/**",
|
||||
]
|
||||
exclude = ["/test-projects/**"]
|
||||
|
||||
[profile.dev]
|
||||
panic = "abort"
|
||||
@@ -51,11 +49,11 @@ memofs = { version = "0.2.0", path = "crates/memofs" }
|
||||
# rbx_reflection_database = { path = "../rbx-dom/rbx_reflection_database" }
|
||||
# rbx_xml = { path = "../rbx-dom/rbx_xml" }
|
||||
|
||||
rbx_binary = "0.6.5"
|
||||
rbx_dom_weak = "2.4.0"
|
||||
rbx_reflection = "4.2.0"
|
||||
rbx_reflection_database = "0.2.2"
|
||||
rbx_xml = "0.12.3"
|
||||
rbx_binary = "0.7.3"
|
||||
rbx_dom_weak = "2.6.0"
|
||||
rbx_reflection = "4.4.0"
|
||||
rbx_reflection_database = "0.2.8"
|
||||
rbx_xml = "0.13.2"
|
||||
|
||||
anyhow = "1.0.44"
|
||||
backtrace = "0.3.61"
|
||||
@@ -72,12 +70,19 @@ jod-thread = "0.1.2"
|
||||
log = "0.4.14"
|
||||
maplit = "1.0.2"
|
||||
notify = "4.0.17"
|
||||
num_cpus = "1.15.0"
|
||||
opener = "0.5.0"
|
||||
reqwest = { version = "0.11.10", features = ["blocking", "json", "native-tls-vendored"] }
|
||||
rayon = "1.7.0"
|
||||
reqwest = { version = "0.11.10", features = [
|
||||
"blocking",
|
||||
"json",
|
||||
"native-tls-vendored",
|
||||
] }
|
||||
ritz = "0.1.0"
|
||||
roblox_install = "1.0.0"
|
||||
serde = { version = "1.0.130", features = ["derive", "rc"] }
|
||||
serde_json = "1.0.68"
|
||||
toml = "0.5.9"
|
||||
termcolor = "1.1.2"
|
||||
thiserror = "1.0.30"
|
||||
tokio = { version = "1.12.0", features = ["rt", "rt-multi-thread"] }
|
||||
@@ -97,12 +102,13 @@ anyhow = "1.0.44"
|
||||
bincode = "1.3.3"
|
||||
fs-err = "2.6.0"
|
||||
maplit = "1.0.2"
|
||||
semver = "1.0.19"
|
||||
|
||||
[dev-dependencies]
|
||||
rojo-insta-ext = { path = "crates/rojo-insta-ext" }
|
||||
|
||||
criterion = "0.3.5"
|
||||
insta = { version = "1.8.0", features = ["redactions"] }
|
||||
insta = { version = "1.8.0", features = ["redactions", "yaml"] }
|
||||
paste = "1.0.5"
|
||||
pretty_assertions = "1.2.1"
|
||||
serde_yaml = "0.8.21"
|
||||
|
||||
@@ -40,7 +40,7 @@ Check out our [contribution guide](CONTRIBUTING.md) for detailed instructions fo
|
||||
|
||||
Pull requests are welcome!
|
||||
|
||||
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.
|
||||
Rojo supports Rust 1.70.0 and newer. The minimum supported version of Rust is based on the latest versions of the dependencies that Rojo has.
|
||||
|
||||
## License
|
||||
Rojo is available under the terms of the Mozilla Public License, Version 2.0. See [LICENSE.txt](LICENSE.txt) for details.
|
||||
@@ -1,5 +1,5 @@
|
||||
[tools]
|
||||
wally = "UpliftGames/wally@0.3.1"
|
||||
rojo = "rojo-rbx/rojo@7.2.1"
|
||||
selene = "Kampfkarren/selene@0.20.0"
|
||||
rojo = "rojo-rbx/rojo@7.3.0"
|
||||
selene = "Kampfkarren/selene@0.25.0"
|
||||
stylua = "JohnnyMorganz/stylua@0.18.2"
|
||||
run-in-roblox = "rojo-rbx/run-in-roblox@0.3.0"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Generated by [Rojo](https://github.com/rojo-rbx/rojo) {rojo_version}.
|
||||
|
||||
## Getting Started
|
||||
To build this library or plugin, use:
|
||||
To build this library, use:
|
||||
|
||||
```bash
|
||||
rojo build -o "{project_name}.rbxmx"
|
||||
|
||||
17
assets/default-plugin-project/README.md
Normal file
17
assets/default-plugin-project/README.md
Normal file
@@ -0,0 +1,17 @@
|
||||
# {project_name}
|
||||
Generated by [Rojo](https://github.com/rojo-rbx/rojo) {rojo_version}.
|
||||
|
||||
## Getting Started
|
||||
To build this plugin to your local plugins folder, use:
|
||||
|
||||
```bash
|
||||
rojo build -p "{project_name}.rbxm"
|
||||
```
|
||||
|
||||
You can include the `watch` flag to re-build it on save:
|
||||
|
||||
```bash
|
||||
rojo build -p "{project_name}.rbxm" --watch
|
||||
```
|
||||
|
||||
For more help, check out [the Rojo documentation](https://rojo.space/docs).
|
||||
6
assets/default-plugin-project/default.project.json
Normal file
6
assets/default-plugin-project/default.project.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "{project_name}",
|
||||
"tree": {
|
||||
"$path": "src"
|
||||
}
|
||||
}
|
||||
3
assets/default-plugin-project/gitignore.txt
Normal file
3
assets/default-plugin-project/gitignore.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# Plugin model files
|
||||
/{project_name}.rbxmx
|
||||
/{project_name}.rbxm
|
||||
@@ -31,11 +31,12 @@ fn bench_build_place(c: &mut Criterion, name: &str, path: &str) {
|
||||
fn place_setup<P: AsRef<Path>>(input_path: P) -> (TempDir, BuildCommand) {
|
||||
let dir = tempdir().unwrap();
|
||||
let input = input_path.as_ref().to_path_buf();
|
||||
let output = dir.path().join("output.rbxlx");
|
||||
let output = Some(dir.path().join("output.rbxlx"));
|
||||
|
||||
let options = BuildCommand {
|
||||
project: input,
|
||||
watch: false,
|
||||
plugin: None,
|
||||
output,
|
||||
};
|
||||
|
||||
|
||||
17
build.rs
17
build.rs
@@ -7,6 +7,7 @@ use fs_err as fs;
|
||||
use fs_err::File;
|
||||
use maplit::hashmap;
|
||||
use memofs::VfsSnapshot;
|
||||
use semver::Version;
|
||||
|
||||
fn snapshot_from_fs_path(path: &Path) -> io::Result<VfsSnapshot> {
|
||||
println!("cargo:rerun-if-changed={}", path.display());
|
||||
@@ -43,6 +44,19 @@ fn main() -> Result<(), anyhow::Error> {
|
||||
let root_dir = env::var_os("CARGO_MANIFEST_DIR").unwrap();
|
||||
let plugin_root = PathBuf::from(root_dir).join("plugin");
|
||||
|
||||
let our_version = Version::parse(env::var_os("CARGO_PKG_VERSION").unwrap().to_str().unwrap())?;
|
||||
let plugin_version =
|
||||
Version::parse(fs::read_to_string(plugin_root.join("Version.txt"))?.trim())?;
|
||||
|
||||
assert!(
|
||||
our_version.major == plugin_version.major,
|
||||
"plugin version does not match Cargo version"
|
||||
);
|
||||
assert!(
|
||||
our_version.minor == plugin_version.minor,
|
||||
"plugin version does not match Cargo version"
|
||||
);
|
||||
|
||||
let snapshot = VfsSnapshot::dir(hashmap! {
|
||||
"default.project.json" => snapshot_from_fs_path(&plugin_root.join("default.project.json"))?,
|
||||
"fmt" => snapshot_from_fs_path(&plugin_root.join("fmt"))?,
|
||||
@@ -51,10 +65,11 @@ fn main() -> Result<(), anyhow::Error> {
|
||||
"rbx_dom_lua" => snapshot_from_fs_path(&plugin_root.join("rbx_dom_lua"))?,
|
||||
"src" => snapshot_from_fs_path(&plugin_root.join("src"))?,
|
||||
"Packages" => snapshot_from_fs_path(&plugin_root.join("Packages"))?,
|
||||
"Version.txt" => snapshot_from_fs_path(&plugin_root.join("Version.txt"))?,
|
||||
});
|
||||
|
||||
let out_path = Path::new(&out_dir).join("plugin.bincode");
|
||||
let out_file = File::create(&out_path)?;
|
||||
let out_file = File::create(out_path)?;
|
||||
|
||||
bincode::serialize_into(out_file, &snapshot)?;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# memofs Changelog
|
||||
|
||||
## Unreleased Changes
|
||||
* Changed the `StdBackend` file watcher to use `PollWatcher` on macOS.
|
||||
|
||||
## 0.2.0 (2021-08-23)
|
||||
* Updated to `crossbeam-channel` 0.5.1.
|
||||
@@ -15,4 +16,4 @@
|
||||
* Improved error messages using the [fs-err](https://crates.io/crates/fs-err) crate.
|
||||
|
||||
## 0.1.0 (2020-03-10)
|
||||
* Initial release
|
||||
* Initial release
|
||||
|
||||
@@ -50,6 +50,12 @@ impl InMemoryFs {
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for InMemoryFs {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct InMemoryFsInner {
|
||||
entries: HashMap<PathBuf, Entry>,
|
||||
|
||||
@@ -194,11 +194,8 @@ impl VfsInner {
|
||||
}
|
||||
|
||||
fn commit_event(&mut self, event: &VfsEvent) -> io::Result<()> {
|
||||
match event {
|
||||
VfsEvent::Remove(path) => {
|
||||
let _ = self.backend.unwatch(&path);
|
||||
}
|
||||
_ => {}
|
||||
if let VfsEvent::Remove(path) = event {
|
||||
let _ = self.backend.unwatch(path);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
|
||||
@@ -74,3 +74,9 @@ impl VfsBackend for NoopBackend {
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for NoopBackend {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,19 +5,34 @@ use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use crossbeam_channel::Receiver;
|
||||
use notify::{watcher, DebouncedEvent, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
use notify::{DebouncedEvent, RecursiveMode, Watcher};
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use notify::PollWatcher;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
use notify::{watcher, RecommendedWatcher};
|
||||
|
||||
use crate::{DirEntry, Metadata, ReadDir, VfsBackend, VfsEvent};
|
||||
|
||||
/// `VfsBackend` that uses `std::fs` and the `notify` crate.
|
||||
pub struct StdBackend {
|
||||
// We use PollWatcher on macos because using the KQueue watcher
|
||||
// can cause some gnarly performance problems.
|
||||
#[cfg(target_os = "macos")]
|
||||
watcher: PollWatcher,
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
watcher: RecommendedWatcher,
|
||||
|
||||
watcher_receiver: Receiver<VfsEvent>,
|
||||
}
|
||||
|
||||
impl StdBackend {
|
||||
pub fn new() -> StdBackend {
|
||||
let (notify_tx, notify_rx) = mpsc::channel();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let watcher = PollWatcher::new(notify_tx, Duration::from_millis(50)).unwrap();
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let watcher = watcher(notify_tx, Duration::from_millis(50)).unwrap();
|
||||
|
||||
let (tx, rx) = crossbeam_channel::unbounded();
|
||||
@@ -108,3 +123,9 @@ impl VfsBackend for StdBackend {
|
||||
.map_err(|inner| io::Error::new(io::ErrorKind::Other, inner))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for StdBackend {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
1
plugin/Packages/Flipper
Submodule
1
plugin/Packages/Flipper
Submodule
Submodule plugin/Packages/Flipper added at 2d91a5ed55
1
plugin/Packages/Highlighter
Submodule
1
plugin/Packages/Highlighter
Submodule
Submodule plugin/Packages/Highlighter added at e0d061449e
1
plugin/Packages/Promise
Submodule
1
plugin/Packages/Promise
Submodule
Submodule plugin/Packages/Promise added at 2c6f433903
1
plugin/Packages/Roact
Submodule
1
plugin/Packages/Roact
Submodule
Submodule plugin/Packages/Roact added at 956891b70f
1
plugin/Packages/TestEZ
Submodule
1
plugin/Packages/TestEZ
Submodule
Submodule plugin/Packages/TestEZ added at edc7246d01
1
plugin/Packages/t
Submodule
1
plugin/Packages/t
Submodule
Submodule plugin/Packages/t added at 1f9754254b
1
plugin/Version.txt
Normal file
1
plugin/Version.txt
Normal file
@@ -0,0 +1 @@
|
||||
7.4.0-rc3
|
||||
@@ -1,25 +1,27 @@
|
||||
{
|
||||
"name": "Rojo",
|
||||
"tree": {
|
||||
"$className": "Folder",
|
||||
"Plugin": {
|
||||
"$path": "src"
|
||||
},
|
||||
"Packages": {
|
||||
"$path": "Packages",
|
||||
|
||||
"Log": {
|
||||
"$path": "log"
|
||||
},
|
||||
"Http": {
|
||||
"$path": "http"
|
||||
},
|
||||
"Fmt": {
|
||||
"$path": "fmt"
|
||||
},
|
||||
"RbxDom": {
|
||||
"$path": "rbx_dom_lua"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"name": "Rojo",
|
||||
"tree": {
|
||||
"$className": "Folder",
|
||||
"Plugin": {
|
||||
"$path": "src"
|
||||
},
|
||||
"Packages": {
|
||||
"$path": "Packages",
|
||||
"Log": {
|
||||
"$path": "log"
|
||||
},
|
||||
"Http": {
|
||||
"$path": "http"
|
||||
},
|
||||
"Fmt": {
|
||||
"$path": "fmt"
|
||||
},
|
||||
"RbxDom": {
|
||||
"$path": "rbx_dom_lua"
|
||||
}
|
||||
},
|
||||
"Version": {
|
||||
"$path": "Version.txt"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,8 +20,8 @@ local function serializeFloat(value)
|
||||
return value
|
||||
end
|
||||
|
||||
local ALL_AXES = {"X", "Y", "Z"}
|
||||
local ALL_FACES = {"Right", "Top", "Back", "Left", "Bottom", "Front"}
|
||||
local ALL_AXES = { "X", "Y", "Z" }
|
||||
local ALL_FACES = { "Right", "Top", "Back", "Left", "Bottom", "Front" }
|
||||
|
||||
local EncodedValue = {}
|
||||
|
||||
@@ -37,7 +37,10 @@ types = {
|
||||
if ok then
|
||||
output[key] = result
|
||||
else
|
||||
local warning = ("Could not decode attribute value of type %q: %s"):format(typeof(value), tostring(result))
|
||||
local warning = ("Could not decode attribute value of type %q: %s"):format(
|
||||
typeof(value),
|
||||
tostring(result)
|
||||
)
|
||||
warn(warning)
|
||||
end
|
||||
end
|
||||
@@ -53,7 +56,10 @@ types = {
|
||||
if ok then
|
||||
output[key] = result
|
||||
else
|
||||
local warning = ("Could not encode attribute value of type %q: %s"):format(typeof(value), tostring(result))
|
||||
local warning = ("Could not encode attribute value of type %q: %s"):format(
|
||||
typeof(value),
|
||||
tostring(result)
|
||||
)
|
||||
warn(warning)
|
||||
end
|
||||
end
|
||||
@@ -111,6 +117,7 @@ types = {
|
||||
local pos = pod.position
|
||||
local orient = pod.orientation
|
||||
|
||||
--stylua: ignore
|
||||
return CFrame.new(
|
||||
pos[1], pos[2], pos[3],
|
||||
orient[1][1], orient[1][2], orient[1][3],
|
||||
@@ -120,17 +127,14 @@ types = {
|
||||
end,
|
||||
|
||||
toPod = function(roblox)
|
||||
local x, y, z,
|
||||
r00, r01, r02,
|
||||
r10, r11, r12,
|
||||
r20, r21, r22 = roblox:GetComponents()
|
||||
local x, y, z, r00, r01, r02, r10, r11, r12, r20, r21, r22 = roblox:GetComponents()
|
||||
|
||||
return {
|
||||
position = {x, y, z},
|
||||
position = { x, y, z },
|
||||
orientation = {
|
||||
{r00, r01, r02},
|
||||
{r10, r11, r12},
|
||||
{r20, r21, r22},
|
||||
{ r00, r01, r02 },
|
||||
{ r10, r11, r12 },
|
||||
{ r20, r21, r22 },
|
||||
},
|
||||
}
|
||||
end,
|
||||
@@ -140,7 +144,7 @@ types = {
|
||||
fromPod = unpackDecoder(Color3.new),
|
||||
|
||||
toPod = function(roblox)
|
||||
return {roblox.r, roblox.g, roblox.b}
|
||||
return { roblox.r, roblox.g, roblox.b }
|
||||
end,
|
||||
},
|
||||
|
||||
@@ -161,10 +165,7 @@ types = {
|
||||
local keypoints = {}
|
||||
|
||||
for index, keypoint in ipairs(pod.keypoints) do
|
||||
keypoints[index] = ColorSequenceKeypoint.new(
|
||||
keypoint.time,
|
||||
types.Color3.fromPod(keypoint.color)
|
||||
)
|
||||
keypoints[index] = ColorSequenceKeypoint.new(keypoint.time, types.Color3.fromPod(keypoint.color))
|
||||
end
|
||||
|
||||
return ColorSequence.new(keypoints)
|
||||
@@ -238,6 +239,23 @@ types = {
|
||||
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 = {
|
||||
fromPod = identity,
|
||||
toPod = identity,
|
||||
@@ -248,11 +266,32 @@ types = {
|
||||
toPod = identity,
|
||||
},
|
||||
|
||||
MaterialColors = {
|
||||
fromPod = function(pod: { [string]: { number } })
|
||||
local real = {}
|
||||
for name, color in pod do
|
||||
real[Enum.Material[name]] = Color3.fromRGB(color[1], color[2], color[3])
|
||||
end
|
||||
return real
|
||||
end,
|
||||
toPod = function(roblox: { [Enum.Material]: Color3 })
|
||||
local pod = {}
|
||||
for material, color in roblox do
|
||||
pod[material.Name] = {
|
||||
math.round(math.clamp(color.R, 0, 1) * 255),
|
||||
math.round(math.clamp(color.G, 0, 1) * 255),
|
||||
math.round(math.clamp(color.B, 0, 1) * 255),
|
||||
}
|
||||
end
|
||||
return pod
|
||||
end,
|
||||
},
|
||||
|
||||
NumberRange = {
|
||||
fromPod = unpackDecoder(NumberRange.new),
|
||||
|
||||
toPod = function(roblox)
|
||||
return {roblox.Min, roblox.Max}
|
||||
return { roblox.Min, roblox.Max }
|
||||
end,
|
||||
},
|
||||
|
||||
@@ -261,11 +300,7 @@ types = {
|
||||
local keypoints = {}
|
||||
|
||||
for index, keypoint in ipairs(pod.keypoints) do
|
||||
keypoints[index] = NumberSequenceKeypoint.new(
|
||||
keypoint.time,
|
||||
keypoint.value,
|
||||
keypoint.envelope
|
||||
)
|
||||
keypoints[index] = NumberSequenceKeypoint.new(keypoint.time, keypoint.value, keypoint.envelope)
|
||||
end
|
||||
|
||||
return NumberSequence.new(keypoints)
|
||||
@@ -320,10 +355,7 @@ types = {
|
||||
|
||||
Ray = {
|
||||
fromPod = function(pod)
|
||||
return Ray.new(
|
||||
types.Vector3.fromPod(pod.origin),
|
||||
types.Vector3.fromPod(pod.direction)
|
||||
)
|
||||
return Ray.new(types.Vector3.fromPod(pod.origin), types.Vector3.fromPod(pod.direction))
|
||||
end,
|
||||
|
||||
toPod = function(roblox)
|
||||
@@ -336,10 +368,7 @@ types = {
|
||||
|
||||
Rect = {
|
||||
fromPod = function(pod)
|
||||
return Rect.new(
|
||||
types.Vector2.fromPod(pod[1]),
|
||||
types.Vector2.fromPod(pod[2])
|
||||
)
|
||||
return Rect.new(types.Vector2.fromPod(pod[1]), types.Vector2.fromPod(pod[2]))
|
||||
end,
|
||||
|
||||
toPod = function(roblox)
|
||||
@@ -351,31 +380,28 @@ types = {
|
||||
},
|
||||
|
||||
Ref = {
|
||||
fromPod = function(_pod)
|
||||
fromPod = function(_)
|
||||
error("Ref cannot be decoded on its own")
|
||||
end,
|
||||
|
||||
toPod = function(_roblox)
|
||||
toPod = function(_)
|
||||
error("Ref can not be encoded on its own")
|
||||
end,
|
||||
},
|
||||
|
||||
Region3 = {
|
||||
fromPod = function(pod)
|
||||
fromPod = function(_)
|
||||
error("Region3 is not implemented")
|
||||
end,
|
||||
|
||||
toPod = function(roblox)
|
||||
toPod = function(_)
|
||||
error("Region3 is not implemented")
|
||||
end,
|
||||
},
|
||||
|
||||
Region3int16 = {
|
||||
fromPod = function(pod)
|
||||
return Region3int16.new(
|
||||
types.Vector3int16.fromPod(pod[1]),
|
||||
types.Vector3int16.fromPod(pod[2])
|
||||
)
|
||||
return Region3int16.new(types.Vector3int16.fromPod(pod[1]), types.Vector3int16.fromPod(pod[2]))
|
||||
end,
|
||||
|
||||
toPod = function(roblox)
|
||||
@@ -387,11 +413,11 @@ types = {
|
||||
},
|
||||
|
||||
SharedString = {
|
||||
fromPod = function(pod)
|
||||
fromPod = function(_pod)
|
||||
error("SharedString is not supported")
|
||||
end,
|
||||
|
||||
toPod = function(roblox)
|
||||
toPod = function(_roblox)
|
||||
error("SharedString is not supported")
|
||||
end,
|
||||
},
|
||||
@@ -405,16 +431,13 @@ types = {
|
||||
fromPod = unpackDecoder(UDim.new),
|
||||
|
||||
toPod = function(roblox)
|
||||
return {roblox.Scale, roblox.Offset}
|
||||
return { roblox.Scale, roblox.Offset }
|
||||
end,
|
||||
},
|
||||
|
||||
UDim2 = {
|
||||
fromPod = function(pod)
|
||||
return UDim2.new(
|
||||
types.UDim.fromPod(pod[1]),
|
||||
types.UDim.fromPod(pod[2])
|
||||
)
|
||||
return UDim2.new(types.UDim.fromPod(pod[1]), types.UDim.fromPod(pod[2]))
|
||||
end,
|
||||
|
||||
toPod = function(roblox)
|
||||
@@ -445,7 +468,7 @@ types = {
|
||||
fromPod = unpackDecoder(Vector2int16.new),
|
||||
|
||||
toPod = function(roblox)
|
||||
return {roblox.X, roblox.Y}
|
||||
return { roblox.X, roblox.Y }
|
||||
end,
|
||||
},
|
||||
|
||||
@@ -465,7 +488,7 @@ types = {
|
||||
fromPod = unpackDecoder(Vector3int16.new),
|
||||
|
||||
toPod = function(roblox)
|
||||
return {roblox.X, roblox.Y, roblox.Z}
|
||||
return { roblox.X, roblox.Y, roblox.Z }
|
||||
end,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
return function()
|
||||
local HttpService = game:GetService("HttpService")
|
||||
|
||||
local EncodedValue = require(script.Parent.EncodedValue)
|
||||
local allValues = require(script.Parent.allValues)
|
||||
|
||||
local function deepEq(a, b)
|
||||
if typeof(a) ~= typeof(b) then
|
||||
return false
|
||||
end
|
||||
|
||||
local ty = typeof(a)
|
||||
|
||||
if ty == "table" then
|
||||
local visited = {}
|
||||
|
||||
for key, valueA in pairs(a) do
|
||||
visited[key] = true
|
||||
|
||||
if not deepEq(valueA, b[key]) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
for key, valueB in pairs(b) do
|
||||
if visited[key] then
|
||||
continue
|
||||
end
|
||||
|
||||
if not deepEq(valueB, a[key]) then
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
return true
|
||||
else
|
||||
return a == b
|
||||
end
|
||||
end
|
||||
|
||||
local extraAssertions = {
|
||||
CFrame = function(value)
|
||||
expect(value).to.equal(CFrame.new(1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12))
|
||||
end,
|
||||
}
|
||||
|
||||
for testName, testEntry in pairs(allValues) do
|
||||
it("round trip " .. testName, function()
|
||||
local ok, decoded = EncodedValue.decode(testEntry.value)
|
||||
assert(ok, decoded)
|
||||
|
||||
if extraAssertions[testName] ~= nil then
|
||||
extraAssertions[testName](decoded)
|
||||
end
|
||||
|
||||
local ok, encoded = EncodedValue.encode(decoded, testEntry.ty)
|
||||
assert(ok, encoded)
|
||||
|
||||
if not deepEq(encoded, testEntry.value) then
|
||||
local expected = HttpService:JSONEncode(testEntry.value)
|
||||
local actual = HttpService:JSONEncode(encoded)
|
||||
|
||||
local message = string.format(
|
||||
"Round-trip results did not match.\nExpected:\n%s\nActual:\n%s",
|
||||
expected, actual
|
||||
)
|
||||
|
||||
error(message)
|
||||
end
|
||||
end)
|
||||
end
|
||||
end
|
||||
@@ -25,4 +25,4 @@ function Error:__tostring()
|
||||
return ("Error(%s: %s)"):format(self.kind, tostring(self.extra))
|
||||
end
|
||||
|
||||
return Error
|
||||
return Error
|
||||
|
||||
@@ -53,6 +53,11 @@ function PropertyDescriptor:read(instance)
|
||||
end
|
||||
|
||||
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]
|
||||
|
||||
return interface.read(instance, self.name)
|
||||
@@ -79,6 +84,11 @@ function PropertyDescriptor:write(instance, value)
|
||||
end
|
||||
|
||||
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]
|
||||
|
||||
return interface.write(instance, self.name, value)
|
||||
|
||||
@@ -207,6 +207,17 @@
|
||||
},
|
||||
"ty": "Float64"
|
||||
},
|
||||
"Font": {
|
||||
"value": {
|
||||
"Font": {
|
||||
"family": "rbxasset://fonts/families/SourceSansPro.json",
|
||||
"weight": "Regular",
|
||||
"style": "Normal",
|
||||
"cachedFaceId": null
|
||||
}
|
||||
},
|
||||
"ty": "Font"
|
||||
},
|
||||
"Int32": {
|
||||
"value": {
|
||||
"Int32": 6014
|
||||
@@ -219,6 +230,118 @@
|
||||
},
|
||||
"ty": "Int64"
|
||||
},
|
||||
"MaterialColors": {
|
||||
"value": {
|
||||
"MaterialColors": {
|
||||
"Grass": [
|
||||
106,
|
||||
127,
|
||||
63
|
||||
],
|
||||
"Slate": [
|
||||
63,
|
||||
127,
|
||||
107
|
||||
],
|
||||
"Concrete": [
|
||||
127,
|
||||
102,
|
||||
63
|
||||
],
|
||||
"Brick": [
|
||||
138,
|
||||
86,
|
||||
62
|
||||
],
|
||||
"Sand": [
|
||||
143,
|
||||
126,
|
||||
95
|
||||
],
|
||||
"WoodPlanks": [
|
||||
139,
|
||||
109,
|
||||
79
|
||||
],
|
||||
"Rock": [
|
||||
102,
|
||||
108,
|
||||
111
|
||||
],
|
||||
"Glacier": [
|
||||
101,
|
||||
176,
|
||||
234
|
||||
],
|
||||
"Snow": [
|
||||
195,
|
||||
199,
|
||||
218
|
||||
],
|
||||
"Sandstone": [
|
||||
137,
|
||||
90,
|
||||
71
|
||||
],
|
||||
"Mud": [
|
||||
58,
|
||||
46,
|
||||
36
|
||||
],
|
||||
"Basalt": [
|
||||
30,
|
||||
30,
|
||||
37
|
||||
],
|
||||
"Ground": [
|
||||
102,
|
||||
92,
|
||||
59
|
||||
],
|
||||
"CrackedLava": [
|
||||
232,
|
||||
156,
|
||||
74
|
||||
],
|
||||
"Asphalt": [
|
||||
115,
|
||||
123,
|
||||
107
|
||||
],
|
||||
"Cobblestone": [
|
||||
132,
|
||||
123,
|
||||
90
|
||||
],
|
||||
"Ice": [
|
||||
129,
|
||||
194,
|
||||
224
|
||||
],
|
||||
"LeafyGrass": [
|
||||
115,
|
||||
132,
|
||||
74
|
||||
],
|
||||
"Salt": [
|
||||
198,
|
||||
189,
|
||||
181
|
||||
],
|
||||
"Limestone": [
|
||||
206,
|
||||
173,
|
||||
148
|
||||
],
|
||||
"Pavement": [
|
||||
148,
|
||||
148,
|
||||
140
|
||||
]
|
||||
}
|
||||
},
|
||||
"ty": "MaterialColors"
|
||||
},
|
||||
"NumberRange": {
|
||||
"value": {
|
||||
"NumberRange": [
|
||||
|
||||
@@ -136,4 +136,4 @@ end
|
||||
return {
|
||||
decode = decodeBase64,
|
||||
encode = encodeBase64,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
return function()
|
||||
local base64 = require(script.Parent.base64)
|
||||
|
||||
it("should encode and decode", function()
|
||||
local function try(str, expected)
|
||||
local encoded = base64.encode(str)
|
||||
expect(encoded).to.equal(expected)
|
||||
expect(base64.decode(encoded)).to.equal(str)
|
||||
end
|
||||
|
||||
try("Man", "TWFu")
|
||||
try("Ma", "TWE=")
|
||||
try("M", "TQ==")
|
||||
try("ManM", "TWFuTQ==")
|
||||
try(
|
||||
[[Man is distinguished, not only by his reason, but by this ]]..
|
||||
[[singular passion from other animals, which is a lust of the ]]..
|
||||
[[mind, that by a perseverance of delight in the continued and ]]..
|
||||
[[indefatigable generation of knowledge, exceeds the short ]]..
|
||||
[[vehemence of any carnal pleasure.]],
|
||||
[[TWFuIGlzIGRpc3Rpbmd1aXNoZWQsIG5vdCBvbmx5IGJ5IGhpcyByZWFzb24sI]]..
|
||||
[[GJ1dCBieSB0aGlzIHNpbmd1bGFyIHBhc3Npb24gZnJvbSBvdGhlciBhbmltYW]]..
|
||||
[[xzLCB3aGljaCBpcyBhIGx1c3Qgb2YgdGhlIG1pbmQsIHRoYXQgYnkgYSBwZXJ]]..
|
||||
[[zZXZlcmFuY2Ugb2YgZGVsaWdodCBpbiB0aGUgY29udGludWVkIGFuZCBpbmRl]]..
|
||||
[[ZmF0aWdhYmxlIGdlbmVyYXRpb24gb2Yga25vd2xlZGdlLCBleGNlZWRzIHRoZ]]..
|
||||
[[SBzaG9ydCB2ZWhlbWVuY2Ugb2YgYW55IGNhcm5hbCBwbGVhc3VyZS4=]]
|
||||
)
|
||||
end)
|
||||
end
|
||||
@@ -1,4 +1,30 @@
|
||||
local CollectionService = game:GetService("CollectionService")
|
||||
local ScriptEditorService = game:GetService("ScriptEditorService")
|
||||
|
||||
--- A list of `Enum.Material` values that are used for Terrain.MaterialColors
|
||||
local TERRAIN_MATERIAL_COLORS = {
|
||||
Enum.Material.Grass,
|
||||
Enum.Material.Slate,
|
||||
Enum.Material.Concrete,
|
||||
Enum.Material.Brick,
|
||||
Enum.Material.Sand,
|
||||
Enum.Material.WoodPlanks,
|
||||
Enum.Material.Rock,
|
||||
Enum.Material.Glacier,
|
||||
Enum.Material.Snow,
|
||||
Enum.Material.Sandstone,
|
||||
Enum.Material.Mud,
|
||||
Enum.Material.Basalt,
|
||||
Enum.Material.Ground,
|
||||
Enum.Material.CrackedLava,
|
||||
Enum.Material.Asphalt,
|
||||
Enum.Material.Cobblestone,
|
||||
Enum.Material.Ice,
|
||||
Enum.Material.LeafyGrass,
|
||||
Enum.Material.Salt,
|
||||
Enum.Material.Limestone,
|
||||
Enum.Material.Pavement,
|
||||
}
|
||||
|
||||
-- Defines how to read and write properties that aren't directly scriptable.
|
||||
--
|
||||
@@ -11,9 +37,24 @@ return {
|
||||
end,
|
||||
write = function(instance, _, value)
|
||||
local existing = instance:GetAttributes()
|
||||
local didAllWritesSucceed = true
|
||||
|
||||
for key, attr in pairs(value) do
|
||||
instance:SetAttribute(key, attr)
|
||||
for attributeName, attributeValue in pairs(value) do
|
||||
local isNameValid =
|
||||
-- For our SetAttribute to succeed, the attribute name must be
|
||||
-- less than or equal to 100 characters...
|
||||
#attributeName <= 100
|
||||
-- ...must only contain alphanumeric characters, periods, hyphens,
|
||||
-- underscores, or forward slashes...
|
||||
and attributeName:match("[^%w%.%-_/]") == nil
|
||||
-- ... and must not use the RBX prefix, which is reserved by Roblox.
|
||||
and attributeName:sub(1, 3) ~= "RBX"
|
||||
|
||||
if isNameValid then
|
||||
instance:SetAttribute(attributeName, attributeValue)
|
||||
else
|
||||
didAllWritesSucceed = false
|
||||
end
|
||||
end
|
||||
|
||||
for key in pairs(existing) do
|
||||
@@ -22,7 +63,7 @@ return {
|
||||
end
|
||||
end
|
||||
|
||||
return true
|
||||
return didAllWritesSucceed
|
||||
end,
|
||||
},
|
||||
Tags = {
|
||||
@@ -52,13 +93,73 @@ return {
|
||||
},
|
||||
LocalizationTable = {
|
||||
Contents = {
|
||||
read = function(instance, key)
|
||||
read = function(instance, _)
|
||||
return true, instance:GetContents()
|
||||
end,
|
||||
write = function(instance, key, value)
|
||||
write = function(instance, _, value)
|
||||
instance:SetContents(value)
|
||||
return true
|
||||
end,
|
||||
},
|
||||
},
|
||||
Model = {
|
||||
Scale = {
|
||||
read = function(instance, _, _)
|
||||
return true, instance:GetScale()
|
||||
end,
|
||||
write = function(instance, _, value)
|
||||
return true, instance:ScaleTo(value)
|
||||
end,
|
||||
},
|
||||
},
|
||||
Terrain = {
|
||||
MaterialColors = {
|
||||
read = function(instance: Terrain)
|
||||
-- There's no way to get a list of every color, so we have to
|
||||
-- make one.
|
||||
local colors = {}
|
||||
for _, material in TERRAIN_MATERIAL_COLORS do
|
||||
colors[material] = instance:GetMaterialColor(material)
|
||||
end
|
||||
|
||||
return true, colors
|
||||
end,
|
||||
write = function(instance: Terrain, _, value: { [Enum.Material]: Color3 })
|
||||
for material, color in value do
|
||||
instance:SetMaterialColor(material, color)
|
||||
end
|
||||
return true
|
||||
end,
|
||||
},
|
||||
},
|
||||
Script = {
|
||||
Source = {
|
||||
read = function(instance: Script)
|
||||
return true, ScriptEditorService:GetEditorSource(instance)
|
||||
end,
|
||||
write = function(instance: Script, _, value: string)
|
||||
task.spawn(function()
|
||||
ScriptEditorService:UpdateSourceAsync(instance, function()
|
||||
return value
|
||||
end)
|
||||
end)
|
||||
return true
|
||||
end,
|
||||
},
|
||||
},
|
||||
ModuleScript = {
|
||||
Source = {
|
||||
read = function(instance: ModuleScript)
|
||||
return true, ScriptEditorService:GetEditorSource(instance)
|
||||
end,
|
||||
write = function(instance: ModuleScript, _, value: string)
|
||||
task.spawn(function()
|
||||
ScriptEditorService:UpdateSourceAsync(instance, function()
|
||||
return value
|
||||
end)
|
||||
end)
|
||||
return true
|
||||
end,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -24,7 +24,8 @@ local function findCanonicalPropertyDescriptor(className, propertyName)
|
||||
return PropertyDescriptor.fromRaw(
|
||||
currentClass.Properties[aliasData.AliasFor],
|
||||
currentClassName,
|
||||
aliasData.AliasFor)
|
||||
aliasData.AliasFor
|
||||
)
|
||||
end
|
||||
|
||||
return nil
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
return function()
|
||||
local RbxDom = require(script.Parent)
|
||||
|
||||
it("should load", function()
|
||||
expect(RbxDom).to.be.ok()
|
||||
end)
|
||||
end
|
||||
@@ -4,16 +4,8 @@ local TestEZ = require(ReplicatedStorage.Packages.TestEZ)
|
||||
|
||||
local Rojo = ReplicatedStorage.Rojo
|
||||
|
||||
local DevSettings = require(Rojo.Plugin.DevSettings)
|
||||
|
||||
local setDevSettings = not DevSettings:hasChangedValues()
|
||||
|
||||
if setDevSettings then
|
||||
DevSettings:createTestSettings()
|
||||
end
|
||||
local Settings = require(Rojo.Plugin.Settings)
|
||||
Settings:set("logLevel", "Trace")
|
||||
Settings:set("typecheckingEnabled", true)
|
||||
|
||||
require(Rojo.Plugin.runTests)(TestEZ)
|
||||
|
||||
if setDevSettings then
|
||||
DevSettings:resetValues()
|
||||
end
|
||||
|
||||
@@ -11,13 +11,6 @@ local validateApiInfo = Types.ifEnabled(Types.ApiInfoResponse)
|
||||
local validateApiRead = Types.ifEnabled(Types.ApiReadResponse)
|
||||
local validateApiSubscribe = Types.ifEnabled(Types.ApiSubscribeResponse)
|
||||
|
||||
--[[
|
||||
Returns a promise that will never resolve nor reject.
|
||||
]]
|
||||
local function hangingPromise()
|
||||
return Promise.new(function() end)
|
||||
end
|
||||
|
||||
local function rejectFailedRequests(response)
|
||||
if response.code >= 400 then
|
||||
local message = string.format("HTTP %s:\n%s", tostring(response.code), response.body)
|
||||
@@ -31,15 +24,17 @@ end
|
||||
local function rejectWrongProtocolVersion(infoResponseBody)
|
||||
if infoResponseBody.protocolVersion ~= Config.protocolVersion then
|
||||
local message = (
|
||||
"Found a Rojo dev server, but it's using a different protocol version, and is incompatible." ..
|
||||
"\nMake sure you have matching versions of both the Rojo plugin and server!" ..
|
||||
"\n\nYour client is version %s, with protocol version %s. It expects server version %s." ..
|
||||
"\nYour server is version %s, with protocol version %s." ..
|
||||
"\n\nGo to https://github.com/rojo-rbx/rojo for more details."
|
||||
"Found a Rojo dev server, but it's using a different protocol version, and is incompatible."
|
||||
.. "\nMake sure you have matching versions of both the Rojo plugin and server!"
|
||||
.. "\n\nYour client is version %s, with protocol version %s. It expects server version %s."
|
||||
.. "\nYour server is version %s, with protocol version %s."
|
||||
.. "\n\nGo to https://github.com/rojo-rbx/rojo for more details."
|
||||
):format(
|
||||
Version.display(Config.version), Config.protocolVersion,
|
||||
Version.display(Config.version),
|
||||
Config.protocolVersion,
|
||||
Config.expectedServerVersionString,
|
||||
infoResponseBody.serverVersion, infoResponseBody.protocolVersion
|
||||
infoResponseBody.serverVersion,
|
||||
infoResponseBody.protocolVersion
|
||||
)
|
||||
|
||||
return Promise.reject(message)
|
||||
@@ -66,14 +61,11 @@ local function rejectWrongPlaceId(infoResponseBody)
|
||||
end
|
||||
|
||||
local message = (
|
||||
"Found a Rojo server, but its project is set to only be used with a specific list of places." ..
|
||||
"\nYour place ID is %s, but needs to be one of these:" ..
|
||||
"\n%s" ..
|
||||
"\n\nTo change this list, edit 'servePlaceIds' in your .project.json file."
|
||||
):format(
|
||||
tostring(game.PlaceId),
|
||||
table.concat(idList, "\n")
|
||||
)
|
||||
"Found a Rojo server, but its project is set to only be used with a specific list of places."
|
||||
.. "\nYour place ID is %s, but needs to be one of these:"
|
||||
.. "\n%s"
|
||||
.. "\n\nTo change this list, edit 'servePlaceIds' in your .project.json file."
|
||||
):format(tostring(game.PlaceId), table.concat(idList, "\n"))
|
||||
|
||||
return Promise.reject(message)
|
||||
end
|
||||
@@ -93,6 +85,7 @@ function ApiContext.new(baseUrl)
|
||||
__sessionId = nil,
|
||||
__messageCursor = -1,
|
||||
__connected = true,
|
||||
__activeRequests = {},
|
||||
}
|
||||
|
||||
return setmetatable(self, ApiContext)
|
||||
@@ -113,6 +106,11 @@ end
|
||||
|
||||
function ApiContext:disconnect()
|
||||
self.__connected = false
|
||||
for request in self.__activeRequests do
|
||||
Log.trace("Cancelling request {}", request)
|
||||
request:cancel()
|
||||
end
|
||||
self.__activeRequests = {}
|
||||
end
|
||||
|
||||
function ApiContext:setMessageCursor(index)
|
||||
@@ -142,18 +140,15 @@ end
|
||||
function ApiContext:read(ids)
|
||||
local url = ("%s/api/read/%s"):format(self.__baseUrl, table.concat(ids, ","))
|
||||
|
||||
return Http.get(url)
|
||||
:andThen(rejectFailedRequests)
|
||||
:andThen(Http.Response.json)
|
||||
:andThen(function(body)
|
||||
if body.sessionId ~= self.__sessionId then
|
||||
return Promise.reject("Server changed ID")
|
||||
end
|
||||
return Http.get(url):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body)
|
||||
if body.sessionId ~= self.__sessionId then
|
||||
return Promise.reject("Server changed ID")
|
||||
end
|
||||
|
||||
assert(validateApiRead(body))
|
||||
assert(validateApiRead(body))
|
||||
|
||||
return body
|
||||
end)
|
||||
return body
|
||||
end)
|
||||
end
|
||||
|
||||
function ApiContext:write(patch)
|
||||
@@ -190,63 +185,58 @@ function ApiContext:write(patch)
|
||||
|
||||
body = Http.jsonEncode(body)
|
||||
|
||||
return Http.post(url, body)
|
||||
:andThen(rejectFailedRequests)
|
||||
:andThen(Http.Response.json)
|
||||
:andThen(function(body)
|
||||
Log.info("Write response: {:?}", body)
|
||||
return Http.post(url, body):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body)
|
||||
Log.info("Write response: {:?}", body)
|
||||
|
||||
return body
|
||||
end)
|
||||
return body
|
||||
end)
|
||||
end
|
||||
|
||||
function ApiContext:retrieveMessages()
|
||||
local url = ("%s/api/subscribe/%s"):format(self.__baseUrl, self.__messageCursor)
|
||||
|
||||
local function sendRequest()
|
||||
return Http.get(url)
|
||||
:catch(function(err)
|
||||
if err.type == Http.Error.Kind.Timeout then
|
||||
if self.__connected then
|
||||
return sendRequest()
|
||||
else
|
||||
return hangingPromise()
|
||||
end
|
||||
end
|
||||
|
||||
return Promise.reject(err)
|
||||
end)
|
||||
end
|
||||
|
||||
return sendRequest()
|
||||
:andThen(rejectFailedRequests)
|
||||
:andThen(Http.Response.json)
|
||||
:andThen(function(body)
|
||||
if body.sessionId ~= self.__sessionId then
|
||||
return Promise.reject("Server changed ID")
|
||||
local request = Http.get(url):catch(function(err)
|
||||
if err.type == Http.Error.Kind.Timeout and self.__connected then
|
||||
return sendRequest()
|
||||
end
|
||||
|
||||
assert(validateApiSubscribe(body))
|
||||
|
||||
self:setMessageCursor(body.messageCursor)
|
||||
|
||||
return body.messages
|
||||
return Promise.reject(err)
|
||||
end)
|
||||
|
||||
Log.trace("Tracking request {}", request)
|
||||
self.__activeRequests[request] = true
|
||||
|
||||
return request:finally(function(...)
|
||||
Log.trace("Cleaning up request {}", request)
|
||||
self.__activeRequests[request] = nil
|
||||
return ...
|
||||
end)
|
||||
end
|
||||
|
||||
return sendRequest():andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body)
|
||||
if body.sessionId ~= self.__sessionId then
|
||||
return Promise.reject("Server changed ID")
|
||||
end
|
||||
|
||||
assert(validateApiSubscribe(body))
|
||||
|
||||
self:setMessageCursor(body.messageCursor)
|
||||
|
||||
return body.messages
|
||||
end)
|
||||
end
|
||||
|
||||
function ApiContext:open(id)
|
||||
local url = ("%s/api/open/%s"):format(self.__baseUrl, id)
|
||||
|
||||
return Http.post(url, "")
|
||||
:andThen(rejectFailedRequests)
|
||||
:andThen(Http.Response.json)
|
||||
:andThen(function(body)
|
||||
if body.sessionId ~= self.__sessionId then
|
||||
return Promise.reject("Server changed ID")
|
||||
end
|
||||
return Http.post(url, ""):andThen(rejectFailedRequests):andThen(Http.Response.json):andThen(function(body)
|
||||
if body.sessionId ~= self.__sessionId then
|
||||
return Promise.reject("Server changed ID")
|
||||
end
|
||||
|
||||
return nil
|
||||
end)
|
||||
return nil
|
||||
end)
|
||||
end
|
||||
|
||||
return ApiContext
|
||||
|
||||
@@ -24,8 +24,10 @@ local function BorderedContainer(props)
|
||||
layoutOrder = props.layoutOrder,
|
||||
}, {
|
||||
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,
|
||||
ZIndex = 2,
|
||||
}, props[Roact.Children]),
|
||||
|
||||
Border = e(SlicedImage, {
|
||||
|
||||
@@ -10,6 +10,7 @@ local Theme = require(Plugin.App.Theme)
|
||||
local bindingUtil = require(Plugin.App.bindingUtil)
|
||||
|
||||
local SlicedImage = require(script.Parent.SlicedImage)
|
||||
local Tooltip = require(script.Parent.Tooltip)
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
@@ -22,12 +23,10 @@ end
|
||||
|
||||
function Checkbox:didUpdate(lastProps)
|
||||
if lastProps.active ~= self.props.active then
|
||||
self.motor:setGoal(
|
||||
Flipper.Spring.new(self.props.active and 1 or 0, {
|
||||
frequency = 6,
|
||||
dampingRatio = 1.1,
|
||||
})
|
||||
)
|
||||
self.motor:setGoal(Flipper.Spring.new(self.props.active and 1 or 0, {
|
||||
frequency = 6,
|
||||
dampingRatio = 1.1,
|
||||
}))
|
||||
end
|
||||
end
|
||||
|
||||
@@ -50,8 +49,18 @@ function Checkbox:render()
|
||||
ZIndex = self.props.zIndex,
|
||||
BackgroundTransparency = 1,
|
||||
|
||||
[Roact.Event.Activated] = self.props.onClick,
|
||||
[Roact.Event.Activated] = function()
|
||||
if self.props.locked then
|
||||
return
|
||||
end
|
||||
self.props.onClick()
|
||||
end,
|
||||
}, {
|
||||
StateTip = e(Tooltip.Trigger, {
|
||||
text = (if self.props.locked then "[LOCKED] " else "")
|
||||
.. (if self.props.active then "Enabled" else "Disabled"),
|
||||
}),
|
||||
|
||||
Active = e(SlicedImage, {
|
||||
slice = Assets.Slices.RoundedBackground,
|
||||
color = theme.Active.BackgroundColor,
|
||||
@@ -60,7 +69,7 @@ function Checkbox:render()
|
||||
zIndex = 2,
|
||||
}, {
|
||||
Icon = e("ImageLabel", {
|
||||
Image = Assets.Images.Checkbox.Active,
|
||||
Image = if self.props.locked then Assets.Images.Checkbox.Locked else Assets.Images.Checkbox.Active,
|
||||
ImageColor3 = theme.Active.IconColor,
|
||||
ImageTransparency = activeTransparency,
|
||||
|
||||
@@ -79,7 +88,9 @@ function Checkbox:render()
|
||||
size = UDim2.new(1, 0, 1, 0),
|
||||
}, {
|
||||
Icon = e("ImageLabel", {
|
||||
Image = Assets.Images.Checkbox.Inactive,
|
||||
Image = if self.props.locked
|
||||
then Assets.Images.Checkbox.Locked
|
||||
else Assets.Images.Checkbox.Inactive,
|
||||
ImageColor3 = theme.Inactive.IconColor,
|
||||
ImageTransparency = self.props.transparency,
|
||||
|
||||
|
||||
61
plugin/src/App/Components/CodeLabel.lua
Normal file
61
plugin/src/App/Components/CodeLabel.lua
Normal file
@@ -0,0 +1,61 @@
|
||||
local Rojo = script:FindFirstAncestor("Rojo")
|
||||
local Packages = Rojo.Packages
|
||||
|
||||
local Roact = require(Packages.Roact)
|
||||
local Highlighter = require(Packages.Highlighter)
|
||||
Highlighter.matchStudioSettings()
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
local CodeLabel = Roact.PureComponent:extend("CodeLabel")
|
||||
|
||||
function CodeLabel:init()
|
||||
self.labelRef = Roact.createRef()
|
||||
self.highlightsRef = Roact.createRef()
|
||||
end
|
||||
|
||||
function CodeLabel:didMount()
|
||||
Highlighter.highlight({
|
||||
textObject = self.labelRef:getValue(),
|
||||
})
|
||||
self:updateHighlights()
|
||||
end
|
||||
|
||||
function CodeLabel:didUpdate()
|
||||
self:updateHighlights()
|
||||
end
|
||||
|
||||
function CodeLabel:updateHighlights()
|
||||
local highlights = self.highlightsRef:getValue()
|
||||
if not highlights then
|
||||
return
|
||||
end
|
||||
|
||||
for _, lineLabel in highlights:GetChildren() do
|
||||
local lineNum = tonumber(string.match(lineLabel.Name, "%d+") or "0")
|
||||
lineLabel.BackgroundColor3 = self.props.lineBackground
|
||||
lineLabel.BorderSizePixel = 0
|
||||
lineLabel.BackgroundTransparency = if self.props.markedLines[lineNum] then 0.25 else 1
|
||||
end
|
||||
end
|
||||
|
||||
function CodeLabel:render()
|
||||
return e("TextLabel", {
|
||||
Size = self.props.size,
|
||||
Position = self.props.position,
|
||||
Text = self.props.text,
|
||||
BackgroundTransparency = 1,
|
||||
Font = Enum.Font.RobotoMono,
|
||||
TextSize = 16,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextYAlignment = Enum.TextYAlignment.Top,
|
||||
TextColor3 = Color3.fromRGB(255, 255, 255),
|
||||
[Roact.Ref] = self.labelRef,
|
||||
}, {
|
||||
SyntaxHighlights = e("Folder", {
|
||||
[Roact.Ref] = self.highlightsRef,
|
||||
}),
|
||||
})
|
||||
end
|
||||
|
||||
return CodeLabel
|
||||
178
plugin/src/App/Components/Dropdown.lua
Normal file
178
plugin/src/App/Components/Dropdown.lua
Normal file
@@ -0,0 +1,178 @@
|
||||
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(prevProps)
|
||||
if self.props.locked and not prevProps.locked then
|
||||
self:setState({
|
||||
open = false,
|
||||
})
|
||||
end
|
||||
|
||||
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()
|
||||
if self.props.locked then
|
||||
return
|
||||
end
|
||||
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()
|
||||
if self.props.locked then
|
||||
return
|
||||
end
|
||||
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 = if self.props.locked then Assets.Images.Dropdown.Locked else 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
|
||||
@@ -30,6 +30,7 @@ function IconButton:render()
|
||||
Position = self.props.position,
|
||||
AnchorPoint = self.props.anchorPoint,
|
||||
|
||||
Visible = self.props.visible,
|
||||
LayoutOrder = self.props.layoutOrder,
|
||||
ZIndex = self.props.zIndex,
|
||||
BackgroundTransparency = 1,
|
||||
@@ -37,15 +38,11 @@ function IconButton:render()
|
||||
[Roact.Event.Activated] = self.props.onClick,
|
||||
|
||||
[Roact.Event.MouseEnter] = function()
|
||||
self.motor:setGoal(
|
||||
Flipper.Spring.new(1, HOVER_SPRING_PROPS)
|
||||
)
|
||||
self.motor:setGoal(Flipper.Spring.new(1, HOVER_SPRING_PROPS))
|
||||
end,
|
||||
|
||||
[Roact.Event.MouseLeave] = function()
|
||||
self.motor:setGoal(
|
||||
Flipper.Spring.new(0, HOVER_SPRING_PROPS)
|
||||
)
|
||||
self.motor:setGoal(Flipper.Spring.new(0, HOVER_SPRING_PROPS))
|
||||
end,
|
||||
}, {
|
||||
Icon = e("ImageLabel", {
|
||||
@@ -74,6 +71,8 @@ function IconButton:render()
|
||||
|
||||
BackgroundTransparency = 1,
|
||||
}),
|
||||
|
||||
Children = Roact.createFragment(self.props[Roact.Children]),
|
||||
})
|
||||
end
|
||||
|
||||
|
||||
265
plugin/src/App/Components/PatchVisualizer/ChangeList.lua
Normal file
265
plugin/src/App/Components/PatchVisualizer/ChangeList.lua
Normal file
@@ -0,0 +1,265 @@
|
||||
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 ScrollingFrame = require(Plugin.App.Components.ScrollingFrame)
|
||||
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
|
||||
local DisplayValue = require(script.Parent.DisplayValue)
|
||||
|
||||
local EMPTY_TABLE = {}
|
||||
|
||||
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 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", {
|
||||
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", {
|
||||
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", {
|
||||
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
|
||||
|
||||
local metadata = values[4] or EMPTY_TABLE
|
||||
local isWarning = metadata.isWarning
|
||||
|
||||
-- Special case for .Source updates
|
||||
-- because we want to display a syntax highlighted diff for better UX
|
||||
if self.props.showSourceDiff and tostring(values[1]) == "Source" then
|
||||
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", {
|
||||
Text = (if isWarning then "⚠ " else "") .. tostring(values[1]),
|
||||
BackgroundTransparency = 1,
|
||||
Font = Enum.Font.GothamMedium,
|
||||
TextSize = 14,
|
||||
TextColor3 = if isWarning then theme.Diff.Warning else 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,
|
||||
}),
|
||||
Button = e("TextButton", {
|
||||
Text = "",
|
||||
Size = UDim2.new(0.7, 0, 1, -4),
|
||||
LayoutOrder = 2,
|
||||
BackgroundTransparency = 1,
|
||||
[Roact.Event.Activated] = function()
|
||||
if props.showSourceDiff then
|
||||
props.showSourceDiff(tostring(values[2]), tostring(values[3]))
|
||||
end
|
||||
end,
|
||||
}, {
|
||||
e(BorderedContainer, {
|
||||
size = UDim2.new(1, 0, 1, 0),
|
||||
transparency = self.props.transparency:map(function(t)
|
||||
return 0.5 + (0.5 * t)
|
||||
end),
|
||||
}, {
|
||||
Layout = e("UIListLayout", {
|
||||
FillDirection = Enum.FillDirection.Horizontal,
|
||||
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||
HorizontalAlignment = Enum.HorizontalAlignment.Center,
|
||||
VerticalAlignment = Enum.VerticalAlignment.Center,
|
||||
Padding = UDim.new(0, 5),
|
||||
}),
|
||||
Label = e("TextLabel", {
|
||||
Text = "View Diff",
|
||||
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, 65, 1, 0),
|
||||
LayoutOrder = 1,
|
||||
}),
|
||||
Icon = e("ImageLabel", {
|
||||
Image = Assets.Images.Icons.Expand,
|
||||
ImageColor3 = theme.Settings.Setting.DescriptionColor,
|
||||
ImageTransparency = self.props.transparency,
|
||||
|
||||
Size = UDim2.new(0, 16, 0, 16),
|
||||
Position = UDim2.new(0.5, 0, 0.5, 0),
|
||||
AnchorPoint = Vector2.new(0.5, 0.5),
|
||||
|
||||
BackgroundTransparency = 1,
|
||||
LayoutOrder = 2,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
continue
|
||||
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", {
|
||||
Text = (if isWarning then "⚠ " else "") .. tostring(values[1]),
|
||||
BackgroundTransparency = 1,
|
||||
Font = Enum.Font.GothamMedium,
|
||||
TextSize = 14,
|
||||
TextColor3 = if isWarning then theme.Diff.Warning else 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",
|
||||
{
|
||||
BackgroundTransparency = 1,
|
||||
Size = UDim2.new(0.35, 0, 1, 0),
|
||||
LayoutOrder = 2,
|
||||
},
|
||||
e(DisplayValue, {
|
||||
value = values[2],
|
||||
transparency = props.transparency,
|
||||
textColor = if isWarning then theme.Diff.Warning else theme.Settings.Setting.DescriptionColor,
|
||||
})
|
||||
),
|
||||
C = e(
|
||||
"Frame",
|
||||
{
|
||||
BackgroundTransparency = 1,
|
||||
Size = UDim2.new(0.35, 0, 1, 0),
|
||||
LayoutOrder = 3,
|
||||
},
|
||||
e(DisplayValue, {
|
||||
value = values[3],
|
||||
transparency = props.transparency,
|
||||
textColor = if isWarning then theme.Diff.Warning else theme.Settings.Setting.DescriptionColor,
|
||||
})
|
||||
),
|
||||
})
|
||||
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
|
||||
106
plugin/src/App/Components/PatchVisualizer/DisplayValue.lua
Normal file
106
plugin/src/App/Components/PatchVisualizer/DisplayValue.lua
Normal file
@@ -0,0 +1,106 @@
|
||||
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 = props.textColor,
|
||||
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 = props.textColor,
|
||||
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 = props.textColor,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextTransparency = props.transparency,
|
||||
TextTruncate = Enum.TextTruncate.AtEnd,
|
||||
Size = UDim2.new(1, 0, 1, 0),
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
return DisplayValue
|
||||
220
plugin/src/App/Components/PatchVisualizer/DomLabel.lua
Normal file
220
plugin/src/App/Components/PatchVisualizer/DomLabel.lua
Normal file
@@ -0,0 +1,220 @@
|
||||
local SelectionService = game:GetService("Selection")
|
||||
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 Tooltip = require(script.Parent.Parent.Tooltip)
|
||||
|
||||
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,
|
||||
showSourceDiff = props.showSourceDiff,
|
||||
}),
|
||||
})
|
||||
end
|
||||
|
||||
local DomLabel = Roact.Component:extend("DomLabel")
|
||||
|
||||
function DomLabel:init()
|
||||
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:didUpdate(prevProps)
|
||||
if
|
||||
prevProps.instance ~= self.props.instance
|
||||
or prevProps.patchType ~= self.props.patchType
|
||||
or prevProps.name ~= self.props.name
|
||||
or prevProps.changeList ~= self.props.changeList
|
||||
then
|
||||
-- Close the expansion when the domlabel is changed to a different thing
|
||||
self.expanded = false
|
||||
self.motor:setGoal(Flipper.Spring.new(30, {
|
||||
frequency = 5,
|
||||
dampingRatio = 1,
|
||||
}))
|
||||
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),
|
||||
}),
|
||||
Button = e("TextButton", {
|
||||
BackgroundTransparency = 1,
|
||||
Text = "",
|
||||
Size = UDim2.new(1, 0, 1, 0),
|
||||
[Roact.Event.Activated] = function(_rbx: Instance, _input: InputObject, clickCount: number)
|
||||
if clickCount == 1 then
|
||||
-- Double click opens the instance in explorer
|
||||
self.lastDoubleClickTime = os.clock()
|
||||
if props.instance then
|
||||
SelectionService:Set({ props.instance })
|
||||
end
|
||||
elseif clickCount == 0 then
|
||||
-- Single click expands the changes
|
||||
task.wait(0.25)
|
||||
if os.clock() - (self.lastDoubleClickTime or 0) <= 0.25 then
|
||||
-- This is a double click, so don't expand
|
||||
return
|
||||
end
|
||||
|
||||
if props.changeList then
|
||||
self.expanded = not self.expanded
|
||||
local goalHeight = 30
|
||||
+ (if self.expanded then math.clamp(#props.changeList * 30, 30, 30 * 6) else 0)
|
||||
self.motor:setGoal(Flipper.Spring.new(goalHeight, {
|
||||
frequency = 5,
|
||||
dampingRatio = 1,
|
||||
}))
|
||||
end
|
||||
end
|
||||
end,
|
||||
}, {
|
||||
StateTip = if (props.instance or props.changeList)
|
||||
then e(Tooltip.Trigger, {
|
||||
text = (if props.changeList
|
||||
then "Click to " .. (if self.expanded then "hide" else "view") .. " changes"
|
||||
else "") .. (if props.instance
|
||||
then (if props.changeList then " & d" else "D") .. "ouble click to open in Explorer"
|
||||
else ""),
|
||||
})
|
||||
else nil,
|
||||
}),
|
||||
Expansion = if props.changeList
|
||||
then e(Expansion, {
|
||||
rendered = self.state.renderExpansion,
|
||||
indent = indent,
|
||||
transparency = props.transparency,
|
||||
changeList = props.changeList,
|
||||
showSourceDiff = props.showSourceDiff,
|
||||
})
|
||||
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 = (if props.isWarning then "⚠ " else "") .. 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 = if props.isWarning then theme.Diff.Warning else 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
|
||||
123
plugin/src/App/Components/PatchVisualizer/init.lua
Normal file
123
plugin/src/App/Components/PatchVisualizer/init.lua
Normal file
@@ -0,0 +1,123 @@
|
||||
local Rojo = script:FindFirstAncestor("Rojo")
|
||||
local Plugin = Rojo.Plugin
|
||||
local Packages = Rojo.Packages
|
||||
|
||||
local Roact = require(Packages.Roact)
|
||||
|
||||
local PatchTree = require(Plugin.PatchTree)
|
||||
local PatchSet = require(Plugin.PatchSet)
|
||||
|
||||
local Theme = require(Plugin.App.Theme)
|
||||
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
|
||||
local VirtualScroller = require(Plugin.App.Components.VirtualScroller)
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
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)
|
||||
if self.props.patchTree ~= nextProps.patchTree then
|
||||
return true
|
||||
end
|
||||
|
||||
local currentPatch, nextPatch = self.props.patch, nextProps.patch
|
||||
if currentPatch ~= nil or nextPatch ~= nil then
|
||||
return not PatchSet.isEqual(currentPatch, nextPatch)
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
function PatchVisualizer:render()
|
||||
local patchTree = self.props.patchTree
|
||||
if patchTree == nil and self.props.patch ~= nil then
|
||||
patchTree = PatchTree.build(
|
||||
self.props.patch,
|
||||
self.props.instanceMap,
|
||||
self.props.changeListHeaders or { "Property", "Current", "Incoming" }
|
||||
)
|
||||
if self.props.unappliedPatch then
|
||||
patchTree =
|
||||
PatchTree.updateMetadata(patchTree, self.props.patch, self.props.instanceMap, self.props.unappliedPatch)
|
||||
end
|
||||
end
|
||||
|
||||
-- Recusively draw tree
|
||||
local scrollElements, elementHeights = {}, {}
|
||||
|
||||
if patchTree then
|
||||
local function drawNode(node, depth)
|
||||
local elementHeight, setElementHeight = Roact.createBinding(30)
|
||||
table.insert(elementHeights, elementHeight)
|
||||
table.insert(
|
||||
scrollElements,
|
||||
e(DomLabel, {
|
||||
updateEvent = self.updateEvent,
|
||||
elementHeight = elementHeight,
|
||||
setElementHeight = setElementHeight,
|
||||
patchType = node.patchType,
|
||||
className = node.className,
|
||||
isWarning = node.isWarning,
|
||||
instance = node.instance,
|
||||
name = node.name,
|
||||
hint = node.hint,
|
||||
changeList = node.changeList,
|
||||
depth = depth,
|
||||
transparency = self.props.transparency,
|
||||
showSourceDiff = self.props.showSourceDiff,
|
||||
})
|
||||
)
|
||||
end
|
||||
|
||||
patchTree:forEach(function(node, depth)
|
||||
drawNode(node, depth)
|
||||
end)
|
||||
end
|
||||
|
||||
return Theme.with(function(theme)
|
||||
return e(BorderedContainer, {
|
||||
transparency = self.props.transparency,
|
||||
size = self.props.size,
|
||||
position = self.props.position,
|
||||
layoutOrder = self.props.layoutOrder,
|
||||
}, {
|
||||
CleanMerge = e("TextLabel", {
|
||||
Visible = #scrollElements == 0,
|
||||
Text = "No changes to sync, project is up to date.",
|
||||
Font = Enum.Font.GothamMedium,
|
||||
TextSize = 15,
|
||||
TextColor3 = theme.Settings.Setting.DescriptionColor,
|
||||
TextWrapped = true,
|
||||
Size = UDim2.new(1, 0, 1, 0),
|
||||
BackgroundTransparency = 1,
|
||||
}),
|
||||
|
||||
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)
|
||||
end
|
||||
|
||||
return PatchVisualizer
|
||||
@@ -23,19 +23,26 @@ local function ScrollingFrame(props)
|
||||
BottomImage = Assets.Images.ScrollBar.Bottom,
|
||||
|
||||
ElasticBehavior = Enum.ElasticBehavior.Always,
|
||||
ScrollingDirection = Enum.ScrollingDirection.Y,
|
||||
ScrollingDirection = props.scrollingDirection or Enum.ScrollingDirection.Y,
|
||||
|
||||
Size = props.size,
|
||||
Position = props.position,
|
||||
AnchorPoint = props.anchorPoint,
|
||||
CanvasSize = props.contentSize:map(function(value)
|
||||
return UDim2.new(0, 0, 0, value.Y)
|
||||
return UDim2.new(
|
||||
0,
|
||||
if (props.scrollingDirection and props.scrollingDirection ~= Enum.ScrollingDirection.Y)
|
||||
then value.X
|
||||
else 0,
|
||||
0,
|
||||
value.Y
|
||||
)
|
||||
end),
|
||||
|
||||
BorderSizePixel = 0,
|
||||
BackgroundTransparency = 1,
|
||||
|
||||
[Roact.Change.AbsoluteSize] = props[Roact.Change.AbsoluteSize]
|
||||
[Roact.Change.AbsoluteSize] = props[Roact.Change.AbsoluteSize],
|
||||
}, props[Roact.Children])
|
||||
end)
|
||||
end
|
||||
|
||||
441
plugin/src/App/Components/StringDiffVisualizer/StringDiff.lua
Normal file
441
plugin/src/App/Components/StringDiffVisualizer/StringDiff.lua
Normal file
@@ -0,0 +1,441 @@
|
||||
--[[
|
||||
Based on DiffMatchPatch by Neil Fraser.
|
||||
https://github.com/google/diff-match-patch
|
||||
]]
|
||||
|
||||
export type DiffAction = number
|
||||
export type Diff = { actionType: DiffAction, value: string }
|
||||
export type Diffs = { Diff }
|
||||
|
||||
local StringDiff = {
|
||||
ActionTypes = table.freeze({
|
||||
Equal = 0,
|
||||
Delete = 1,
|
||||
Insert = 2,
|
||||
}),
|
||||
}
|
||||
|
||||
function StringDiff.findDiffs(text1: string, text2: string): Diffs
|
||||
-- Validate inputs
|
||||
if type(text1) ~= "string" or type(text2) ~= "string" then
|
||||
error(
|
||||
string.format(
|
||||
"Invalid inputs to StringDiff.findDiffs, expected strings and got (%s, %s)",
|
||||
type(text1),
|
||||
type(text2)
|
||||
),
|
||||
2
|
||||
)
|
||||
end
|
||||
|
||||
-- Shortcut if the texts are identical
|
||||
if text1 == text2 then
|
||||
return { { actionType = StringDiff.ActionTypes.Equal, value = text1 } }
|
||||
end
|
||||
|
||||
-- Trim off any shared prefix and suffix
|
||||
-- These are easy to detect and can be dealt with quickly without needing a complex diff
|
||||
-- and later we simply add them as Equal to the start and end of the diff
|
||||
local sharedPrefix, sharedSuffix
|
||||
local prefixLength = StringDiff._sharedPrefix(text1, text2)
|
||||
if prefixLength > 0 then
|
||||
-- Store the prefix
|
||||
sharedPrefix = string.sub(text1, 1, prefixLength)
|
||||
-- Now trim it off
|
||||
text1 = string.sub(text1, prefixLength + 1)
|
||||
text2 = string.sub(text2, prefixLength + 1)
|
||||
end
|
||||
|
||||
local suffixLength = StringDiff._sharedSuffix(text1, text2)
|
||||
if suffixLength > 0 then
|
||||
-- Store the suffix
|
||||
sharedSuffix = string.sub(text1, -suffixLength)
|
||||
-- Now trim it off
|
||||
text1 = string.sub(text1, 1, -suffixLength - 1)
|
||||
text2 = string.sub(text2, 1, -suffixLength - 1)
|
||||
end
|
||||
|
||||
-- Compute the diff on the middle block where the changes lie
|
||||
local diffs = StringDiff._computeDiff(text1, text2)
|
||||
|
||||
-- Restore the prefix and suffix
|
||||
if sharedPrefix then
|
||||
table.insert(diffs, 1, { actionType = StringDiff.ActionTypes.Equal, value = sharedPrefix })
|
||||
end
|
||||
if sharedSuffix then
|
||||
table.insert(diffs, { actionType = StringDiff.ActionTypes.Equal, value = sharedSuffix })
|
||||
end
|
||||
|
||||
-- Cleanup the diff
|
||||
diffs = StringDiff._reorderAndMerge(diffs)
|
||||
|
||||
return diffs
|
||||
end
|
||||
|
||||
function StringDiff._sharedPrefix(text1: string, text2: string): number
|
||||
-- Uses a binary search to find the largest common prefix between the two strings
|
||||
-- Performance analysis: http://neil.fraser.name/news/2007/10/09/
|
||||
|
||||
-- Shortcut common cases
|
||||
if (#text1 == 0) or (#text2 == 0) or (string.byte(text1, 1) ~= string.byte(text2, 1)) then
|
||||
return 0
|
||||
end
|
||||
|
||||
local pointerMin = 1
|
||||
local pointerMax = math.min(#text1, #text2)
|
||||
local pointerMid = pointerMax
|
||||
local pointerStart = 1
|
||||
while pointerMin < pointerMid do
|
||||
if string.sub(text1, pointerStart, pointerMid) == string.sub(text2, pointerStart, pointerMid) then
|
||||
pointerMin = pointerMid
|
||||
pointerStart = pointerMin
|
||||
else
|
||||
pointerMax = pointerMid
|
||||
end
|
||||
pointerMid = math.floor(pointerMin + (pointerMax - pointerMin) / 2)
|
||||
end
|
||||
|
||||
return pointerMid
|
||||
end
|
||||
|
||||
function StringDiff._sharedSuffix(text1: string, text2: string): number
|
||||
-- Uses a binary search to find the largest common suffix between the two strings
|
||||
-- Performance analysis: http://neil.fraser.name/news/2007/10/09/
|
||||
|
||||
-- Shortcut common cases
|
||||
if (#text1 == 0) or (#text2 == 0) or (string.byte(text1, -1) ~= string.byte(text2, -1)) then
|
||||
return 0
|
||||
end
|
||||
|
||||
local pointerMin = 1
|
||||
local pointerMax = math.min(#text1, #text2)
|
||||
local pointerMid = pointerMax
|
||||
local pointerEnd = 1
|
||||
while pointerMin < pointerMid do
|
||||
if string.sub(text1, -pointerMid, -pointerEnd) == string.sub(text2, -pointerMid, -pointerEnd) then
|
||||
pointerMin = pointerMid
|
||||
pointerEnd = pointerMin
|
||||
else
|
||||
pointerMax = pointerMid
|
||||
end
|
||||
pointerMid = math.floor(pointerMin + (pointerMax - pointerMin) / 2)
|
||||
end
|
||||
|
||||
return pointerMid
|
||||
end
|
||||
|
||||
function StringDiff._computeDiff(text1: string, text2: string): Diffs
|
||||
-- Assumes that the prefix and suffix have already been trimmed off
|
||||
-- and shortcut returns have been made so these texts must be different
|
||||
|
||||
local text1Length, text2Length = #text1, #text2
|
||||
|
||||
if text1Length == 0 then
|
||||
-- It's simply inserting all of text2 into text1
|
||||
return { { actionType = StringDiff.ActionTypes.Insert, value = text2 } }
|
||||
end
|
||||
|
||||
if text2Length == 0 then
|
||||
-- It's simply deleting all of text1
|
||||
return { { actionType = StringDiff.ActionTypes.Delete, value = text1 } }
|
||||
end
|
||||
|
||||
local longText = if text1Length > text2Length then text1 else text2
|
||||
local shortText = if text1Length > text2Length then text2 else text1
|
||||
local shortTextLength = #shortText
|
||||
|
||||
-- Shortcut if the shorter string exists entirely inside the longer one
|
||||
local indexOf = if shortTextLength == 0 then nil else string.find(longText, shortText, 1, true)
|
||||
if indexOf ~= nil then
|
||||
local diffs = {
|
||||
{ actionType = StringDiff.ActionTypes.Insert, value = string.sub(longText, 1, indexOf - 1) },
|
||||
{ actionType = StringDiff.ActionTypes.Equal, value = shortText },
|
||||
{ actionType = StringDiff.ActionTypes.Insert, value = string.sub(longText, indexOf + shortTextLength) },
|
||||
}
|
||||
-- Swap insertions for deletions if diff is reversed
|
||||
if text1Length > text2Length then
|
||||
diffs[1].actionType, diffs[3].actionType = StringDiff.ActionTypes.Delete, StringDiff.ActionTypes.Delete
|
||||
end
|
||||
return diffs
|
||||
end
|
||||
|
||||
if shortTextLength == 1 then
|
||||
-- Single character string
|
||||
-- After the previous shortcut, the character can't be an equality
|
||||
return {
|
||||
{ actionType = StringDiff.ActionTypes.Delete, value = text1 },
|
||||
{ actionType = StringDiff.ActionTypes.Insert, value = text2 },
|
||||
}
|
||||
end
|
||||
|
||||
return StringDiff._bisect(text1, text2)
|
||||
end
|
||||
|
||||
function StringDiff._bisect(text1: string, text2: string): Diffs
|
||||
-- Find the 'middle snake' of a diff, split the problem in two
|
||||
-- and return the recursively constructed diff
|
||||
-- See Myers 1986 paper: An O(ND) Difference Algorithm and Its Variations
|
||||
|
||||
-- Cache the text lengths to prevent multiple calls
|
||||
local text1Length = #text1
|
||||
local text2Length = #text2
|
||||
|
||||
local _sub, _element
|
||||
local maxD = math.ceil((text1Length + text2Length) / 2)
|
||||
local vOffset = maxD
|
||||
local vLength = 2 * maxD
|
||||
local v1 = table.create(vLength)
|
||||
local v2 = table.create(vLength)
|
||||
|
||||
-- Setting all elements to -1 is faster in Lua than mixing integers and nil
|
||||
for x = 0, vLength - 1 do
|
||||
v1[x] = -1
|
||||
v2[x] = -1
|
||||
end
|
||||
v1[vOffset + 1] = 0
|
||||
v2[vOffset + 1] = 0
|
||||
local delta = text1Length - text2Length
|
||||
|
||||
-- If the total number of characters is odd, then
|
||||
-- the front path will collide with the reverse path
|
||||
local front = (delta % 2 ~= 0)
|
||||
|
||||
-- Offsets for start and end of k loop
|
||||
-- Prevents mapping of space beyond the grid
|
||||
local k1Start = 0
|
||||
local k1End = 0
|
||||
local k2Start = 0
|
||||
local k2End = 0
|
||||
for d = 0, maxD - 1 do
|
||||
-- Walk the front path one step
|
||||
for k1 = -d + k1Start, d - k1End, 2 do
|
||||
local k1_offset = vOffset + k1
|
||||
local x1
|
||||
if (k1 == -d) or ((k1 ~= d) and (v1[k1_offset - 1] < v1[k1_offset + 1])) then
|
||||
x1 = v1[k1_offset + 1]
|
||||
else
|
||||
x1 = v1[k1_offset - 1] + 1
|
||||
end
|
||||
local y1 = x1 - k1
|
||||
while
|
||||
(x1 <= text1Length)
|
||||
and (y1 <= text2Length)
|
||||
and (string.sub(text1, x1, x1) == string.sub(text2, y1, y1))
|
||||
do
|
||||
x1 = x1 + 1
|
||||
y1 = y1 + 1
|
||||
end
|
||||
v1[k1_offset] = x1
|
||||
if x1 > text1Length + 1 then
|
||||
-- Ran off the right of the graph
|
||||
k1End = k1End + 2
|
||||
elseif y1 > text2Length + 1 then
|
||||
-- Ran off the bottom of the graph
|
||||
k1Start = k1Start + 2
|
||||
elseif front then
|
||||
local k2_offset = vOffset + delta - k1
|
||||
if k2_offset >= 0 and k2_offset < vLength and v2[k2_offset] ~= -1 then
|
||||
-- Mirror x2 onto top-left coordinate system
|
||||
local x2 = text1Length - v2[k2_offset] + 1
|
||||
if x1 > x2 then
|
||||
-- Overlap detected
|
||||
return StringDiff._bisectSplit(text1, text2, x1, y1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Walk the reverse path one step
|
||||
for k2 = -d + k2Start, d - k2End, 2 do
|
||||
local k2_offset = vOffset + k2
|
||||
local x2
|
||||
if (k2 == -d) or ((k2 ~= d) and (v2[k2_offset - 1] < v2[k2_offset + 1])) then
|
||||
x2 = v2[k2_offset + 1]
|
||||
else
|
||||
x2 = v2[k2_offset - 1] + 1
|
||||
end
|
||||
local y2 = x2 - k2
|
||||
while
|
||||
(x2 <= text1Length)
|
||||
and (y2 <= text2Length)
|
||||
and (string.sub(text1, -x2, -x2) == string.sub(text2, -y2, -y2))
|
||||
do
|
||||
x2 = x2 + 1
|
||||
y2 = y2 + 1
|
||||
end
|
||||
v2[k2_offset] = x2
|
||||
if x2 > text1Length + 1 then
|
||||
-- Ran off the left of the graph
|
||||
k2End = k2End + 2
|
||||
elseif y2 > text2Length + 1 then
|
||||
-- Ran off the top of the graph
|
||||
k2Start = k2Start + 2
|
||||
elseif not front then
|
||||
local k1_offset = vOffset + delta - k2
|
||||
if k1_offset >= 0 and k1_offset < vLength and v1[k1_offset] ~= -1 then
|
||||
local x1 = v1[k1_offset]
|
||||
local y1 = vOffset + x1 - k1_offset
|
||||
-- Mirror x2 onto top-left coordinate system
|
||||
x2 = text1Length - x2 + 1
|
||||
if x1 > x2 then
|
||||
-- Overlap detected
|
||||
return StringDiff._bisectSplit(text1, text2, x1, y1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Number of diffs equals number of characters, no commonality at all
|
||||
return {
|
||||
{ actionType = StringDiff.ActionTypes.Delete, value = text1 },
|
||||
{ actionType = StringDiff.ActionTypes.Insert, value = text2 },
|
||||
}
|
||||
end
|
||||
|
||||
function StringDiff._bisectSplit(text1: string, text2: string, x: number, y: number): Diffs
|
||||
-- Given the location of the 'middle snake',
|
||||
-- split the diff in two parts and recurse
|
||||
|
||||
local text1a = string.sub(text1, 1, x - 1)
|
||||
local text2a = string.sub(text2, 1, y - 1)
|
||||
local text1b = string.sub(text1, x)
|
||||
local text2b = string.sub(text2, y)
|
||||
|
||||
-- Compute both diffs serially
|
||||
local diffs = StringDiff.findDiffs(text1a, text2a)
|
||||
local diffsB = StringDiff.findDiffs(text1b, text2b)
|
||||
|
||||
-- Merge diffs
|
||||
table.move(diffsB, 1, #diffsB, #diffs + 1, diffs)
|
||||
return diffs
|
||||
end
|
||||
|
||||
function StringDiff._reorderAndMerge(diffs: Diffs): Diffs
|
||||
-- Reorder and merge like edit sections and merge equalities
|
||||
-- Any edit section can move as long as it doesn't cross an equality
|
||||
|
||||
-- Add a dummy entry at the end
|
||||
table.insert(diffs, { actionType = StringDiff.ActionTypes.Equal, value = "" })
|
||||
|
||||
local pointer = 1
|
||||
local countDelete, countInsert = 0, 0
|
||||
local textDelete, textInsert = "", ""
|
||||
local commonLength
|
||||
while diffs[pointer] do
|
||||
local actionType = diffs[pointer].actionType
|
||||
if actionType == StringDiff.ActionTypes.Insert then
|
||||
countInsert = countInsert + 1
|
||||
textInsert = textInsert .. diffs[pointer].value
|
||||
pointer = pointer + 1
|
||||
elseif actionType == StringDiff.ActionTypes.Delete then
|
||||
countDelete = countDelete + 1
|
||||
textDelete = textDelete .. diffs[pointer].value
|
||||
pointer = pointer + 1
|
||||
elseif actionType == StringDiff.ActionTypes.Equal then
|
||||
-- Upon reaching an equality, check for prior redundancies
|
||||
if countDelete + countInsert > 1 then
|
||||
if (countDelete > 0) and (countInsert > 0) then
|
||||
-- Factor out any common prefixies
|
||||
commonLength = StringDiff._sharedPrefix(textInsert, textDelete)
|
||||
if commonLength > 0 then
|
||||
local back_pointer = pointer - countDelete - countInsert
|
||||
if
|
||||
(back_pointer > 1) and (diffs[back_pointer - 1].actionType == StringDiff.ActionTypes.Equal)
|
||||
then
|
||||
diffs[back_pointer - 1].value = diffs[back_pointer - 1].value
|
||||
.. string.sub(textInsert, 1, commonLength)
|
||||
else
|
||||
table.insert(diffs, 1, {
|
||||
actionType = StringDiff.ActionTypes.Equal,
|
||||
value = string.sub(textInsert, 1, commonLength),
|
||||
})
|
||||
pointer = pointer + 1
|
||||
end
|
||||
textInsert = string.sub(textInsert, commonLength + 1)
|
||||
textDelete = string.sub(textDelete, commonLength + 1)
|
||||
end
|
||||
-- Factor out any common suffixies
|
||||
commonLength = StringDiff._sharedSuffix(textInsert, textDelete)
|
||||
if commonLength ~= 0 then
|
||||
diffs[pointer].value = string.sub(textInsert, -commonLength) .. diffs[pointer].value
|
||||
textInsert = string.sub(textInsert, 1, -commonLength - 1)
|
||||
textDelete = string.sub(textDelete, 1, -commonLength - 1)
|
||||
end
|
||||
end
|
||||
-- Delete the offending records and add the merged ones
|
||||
pointer = pointer - countDelete - countInsert
|
||||
for _ = 1, countDelete + countInsert do
|
||||
table.remove(diffs, pointer)
|
||||
end
|
||||
if #textDelete > 0 then
|
||||
table.insert(diffs, pointer, { actionType = StringDiff.ActionTypes.Delete, value = textDelete })
|
||||
pointer = pointer + 1
|
||||
end
|
||||
if #textInsert > 0 then
|
||||
table.insert(diffs, pointer, { actionType = StringDiff.ActionTypes.Insert, value = textInsert })
|
||||
pointer = pointer + 1
|
||||
end
|
||||
pointer = pointer + 1
|
||||
elseif (pointer > 1) and (diffs[pointer - 1].actionType == StringDiff.ActionTypes.Equal) then
|
||||
-- Merge this equality with the previous one
|
||||
diffs[pointer - 1].value = diffs[pointer - 1].value .. diffs[pointer].value
|
||||
table.remove(diffs, pointer)
|
||||
else
|
||||
pointer = pointer + 1
|
||||
end
|
||||
countInsert, countDelete = 0, 0
|
||||
textDelete, textInsert = "", ""
|
||||
end
|
||||
end
|
||||
if diffs[#diffs].value == "" then
|
||||
-- Remove the dummy entry at the end
|
||||
diffs[#diffs] = nil
|
||||
end
|
||||
|
||||
-- Second pass: look for single edits surrounded on both sides by equalities
|
||||
-- which can be shifted sideways to eliminate an equality
|
||||
-- e.g: A<ins>BA</ins>C -> <ins>AB</ins>AC
|
||||
local changes = false
|
||||
pointer = 2
|
||||
-- Intentionally ignore the first and last element (don't need checking)
|
||||
while pointer < #diffs do
|
||||
local prevDiff, nextDiff = diffs[pointer - 1], diffs[pointer + 1]
|
||||
if
|
||||
(prevDiff.actionType == StringDiff.ActionTypes.Equal)
|
||||
and (nextDiff.actionType == StringDiff.ActionTypes.Equal)
|
||||
then
|
||||
-- This is a single edit surrounded by equalities
|
||||
local currentDiff = diffs[pointer]
|
||||
local currentText = currentDiff.value
|
||||
local prevText = prevDiff.value
|
||||
local nextText = nextDiff.value
|
||||
if #prevText == 0 then
|
||||
table.remove(diffs, pointer - 1)
|
||||
changes = true
|
||||
elseif string.sub(currentText, -#prevText) == prevText then
|
||||
-- Shift the edit over the previous equality
|
||||
currentDiff.value = prevText .. string.sub(currentText, 1, -#prevText - 1)
|
||||
nextDiff.value = prevText .. nextDiff.value
|
||||
table.remove(diffs, pointer - 1)
|
||||
changes = true
|
||||
elseif string.sub(currentText, 1, #nextText) == nextText then
|
||||
-- Shift the edit over the next equality
|
||||
prevDiff.value = prevText .. nextText
|
||||
currentDiff.value = string.sub(currentText, #nextText + 1) .. nextText
|
||||
table.remove(diffs, pointer + 1)
|
||||
changes = true
|
||||
end
|
||||
end
|
||||
pointer = pointer + 1
|
||||
end
|
||||
|
||||
-- If shifts were made, the diffs need reordering and another shift sweep
|
||||
if changes then
|
||||
return StringDiff._reorderAndMerge(diffs)
|
||||
end
|
||||
|
||||
return diffs
|
||||
end
|
||||
|
||||
return StringDiff
|
||||
202
plugin/src/App/Components/StringDiffVisualizer/init.lua
Normal file
202
plugin/src/App/Components/StringDiffVisualizer/init.lua
Normal file
@@ -0,0 +1,202 @@
|
||||
local TextService = game:GetService("TextService")
|
||||
|
||||
local Rojo = script:FindFirstAncestor("Rojo")
|
||||
local Plugin = Rojo.Plugin
|
||||
local Packages = Rojo.Packages
|
||||
|
||||
local Roact = require(Packages.Roact)
|
||||
local Log = require(Packages.Log)
|
||||
local Highlighter = require(Packages.Highlighter)
|
||||
local StringDiff = require(script:FindFirstChild("StringDiff"))
|
||||
|
||||
local Theme = require(Plugin.App.Theme)
|
||||
|
||||
local CodeLabel = require(Plugin.App.Components.CodeLabel)
|
||||
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
|
||||
local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame)
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
local StringDiffVisualizer = Roact.Component:extend("StringDiffVisualizer")
|
||||
|
||||
function StringDiffVisualizer:init()
|
||||
self.scriptBackground, self.setScriptBackground = Roact.createBinding(Color3.fromRGB(0, 0, 0))
|
||||
self.contentSize, self.setContentSize = Roact.createBinding(Vector2.new(0, 0))
|
||||
|
||||
-- Ensure that the script background is up to date with the current theme
|
||||
self.themeChangedConnection = settings().Studio.ThemeChanged:Connect(function()
|
||||
task.defer(function()
|
||||
-- Defer to allow Highlighter to process the theme change first
|
||||
self:updateScriptBackground()
|
||||
end)
|
||||
end)
|
||||
|
||||
self:calculateContentSize()
|
||||
self:updateScriptBackground()
|
||||
|
||||
self:setState({
|
||||
add = {},
|
||||
remove = {},
|
||||
})
|
||||
end
|
||||
|
||||
function StringDiffVisualizer:willUnmount()
|
||||
self.themeChangedConnection:Disconnect()
|
||||
end
|
||||
|
||||
function StringDiffVisualizer:updateScriptBackground()
|
||||
local backgroundColor = Highlighter.getTokenColor("background")
|
||||
if backgroundColor ~= self.scriptBackground:getValue() then
|
||||
self.setScriptBackground(backgroundColor)
|
||||
end
|
||||
end
|
||||
|
||||
function StringDiffVisualizer:didUpdate(previousProps)
|
||||
if previousProps.oldText ~= self.props.oldText or previousProps.newText ~= self.props.newText then
|
||||
self:calculateContentSize()
|
||||
local add, remove = self:calculateDiffLines()
|
||||
self:setState({
|
||||
add = add,
|
||||
remove = remove,
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
function StringDiffVisualizer:calculateContentSize()
|
||||
local oldText, newText = self.props.oldText, self.props.newText
|
||||
|
||||
local oldTextBounds = TextService:GetTextSize(oldText, 16, Enum.Font.RobotoMono, Vector2.new(99999, 99999))
|
||||
local newTextBounds = TextService:GetTextSize(newText, 16, Enum.Font.RobotoMono, Vector2.new(99999, 99999))
|
||||
|
||||
self.setContentSize(
|
||||
Vector2.new(math.max(oldTextBounds.X, newTextBounds.X), math.max(oldTextBounds.Y, newTextBounds.Y))
|
||||
)
|
||||
end
|
||||
|
||||
function StringDiffVisualizer:calculateDiffLines()
|
||||
local oldText, newText = self.props.oldText, self.props.newText
|
||||
|
||||
-- Diff the two texts
|
||||
local startClock = os.clock()
|
||||
local diffs = StringDiff.findDiffs(oldText, newText)
|
||||
local stopClock = os.clock()
|
||||
|
||||
Log.trace(
|
||||
"Diffing {} byte and {} byte strings took {} microseconds and found {} diff sections",
|
||||
#oldText,
|
||||
#newText,
|
||||
math.round((stopClock - startClock) * 1000 * 1000),
|
||||
#diffs
|
||||
)
|
||||
|
||||
-- Determine which lines to highlight
|
||||
local add, remove = {}, {}
|
||||
|
||||
local oldLineNum, newLineNum = 1, 1
|
||||
for _, diff in diffs do
|
||||
local actionType, text = diff.actionType, diff.value
|
||||
local lines = select(2, string.gsub(text, "\n", "\n"))
|
||||
|
||||
if actionType == StringDiff.ActionTypes.Equal then
|
||||
oldLineNum += lines
|
||||
newLineNum += lines
|
||||
elseif actionType == StringDiff.ActionTypes.Insert then
|
||||
if lines > 0 then
|
||||
local textLines = string.split(text, "\n")
|
||||
for i, textLine in textLines do
|
||||
if string.match(textLine, "%S") then
|
||||
add[newLineNum + i - 1] = true
|
||||
end
|
||||
end
|
||||
else
|
||||
if string.match(text, "%S") then
|
||||
add[newLineNum] = true
|
||||
end
|
||||
end
|
||||
newLineNum += lines
|
||||
elseif actionType == StringDiff.ActionTypes.Delete then
|
||||
if lines > 0 then
|
||||
local textLines = string.split(text, "\n")
|
||||
for i, textLine in textLines do
|
||||
if string.match(textLine, "%S") then
|
||||
remove[oldLineNum + i - 1] = true
|
||||
end
|
||||
end
|
||||
else
|
||||
if string.match(text, "%S") then
|
||||
remove[oldLineNum] = true
|
||||
end
|
||||
end
|
||||
oldLineNum += lines
|
||||
else
|
||||
Log.warn("Unknown diff action: {} {}", actionType, text)
|
||||
end
|
||||
end
|
||||
|
||||
return add, remove
|
||||
end
|
||||
|
||||
function StringDiffVisualizer:render()
|
||||
local oldText, newText = self.props.oldText, self.props.newText
|
||||
|
||||
return Theme.with(function(theme)
|
||||
return e(BorderedContainer, {
|
||||
size = self.props.size,
|
||||
position = self.props.position,
|
||||
anchorPoint = self.props.anchorPoint,
|
||||
transparency = self.props.transparency,
|
||||
}, {
|
||||
Background = e("Frame", {
|
||||
Size = UDim2.new(1, 0, 1, 0),
|
||||
Position = UDim2.new(0, 0, 0, 0),
|
||||
BorderSizePixel = 0,
|
||||
BackgroundColor3 = self.scriptBackground,
|
||||
ZIndex = -10,
|
||||
}, {
|
||||
UICorner = e("UICorner", {
|
||||
CornerRadius = UDim.new(0, 5),
|
||||
}),
|
||||
}),
|
||||
Separator = e("Frame", {
|
||||
Size = UDim2.new(0, 2, 1, 0),
|
||||
Position = UDim2.new(0.5, 0, 0, 0),
|
||||
AnchorPoint = Vector2.new(0.5, 0),
|
||||
BorderSizePixel = 0,
|
||||
BackgroundColor3 = theme.BorderedContainer.BorderColor,
|
||||
BackgroundTransparency = 0.5,
|
||||
}),
|
||||
Old = e(ScrollingFrame, {
|
||||
position = UDim2.new(0, 2, 0, 2),
|
||||
size = UDim2.new(0.5, -7, 1, -4),
|
||||
scrollingDirection = Enum.ScrollingDirection.XY,
|
||||
transparency = self.props.transparency,
|
||||
contentSize = self.contentSize,
|
||||
}, {
|
||||
Source = e(CodeLabel, {
|
||||
size = UDim2.new(1, 0, 1, 0),
|
||||
position = UDim2.new(0, 0, 0, 0),
|
||||
text = oldText,
|
||||
lineBackground = theme.Diff.Remove,
|
||||
markedLines = self.state.remove,
|
||||
}),
|
||||
}),
|
||||
New = e(ScrollingFrame, {
|
||||
position = UDim2.new(0.5, 5, 0, 2),
|
||||
size = UDim2.new(0.5, -7, 1, -4),
|
||||
scrollingDirection = Enum.ScrollingDirection.XY,
|
||||
transparency = self.props.transparency,
|
||||
contentSize = self.contentSize,
|
||||
}, {
|
||||
Source = e(CodeLabel, {
|
||||
size = UDim2.new(1, 0, 1, 0),
|
||||
position = UDim2.new(0, 0, 0, 0),
|
||||
text = newText,
|
||||
lineBackground = theme.Diff.Add,
|
||||
markedLines = self.state.add,
|
||||
}),
|
||||
}),
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
return StringDiffVisualizer
|
||||
@@ -10,11 +10,15 @@ local StudioPluginContext = require(script.Parent.StudioPluginContext)
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
local StudioPluginAction = Roact.Component:extend("StudioPluginAction")
|
||||
local StudioPluginAction = Roact.Component:extend("StudioPluginAction")
|
||||
|
||||
function StudioPluginAction:init()
|
||||
self.pluginAction = self.props.plugin:CreatePluginAction(
|
||||
self.props.name, self.props.title, self.props.description, self.props.icon, self.props.bindable
|
||||
self.props.name,
|
||||
self.props.title,
|
||||
self.props.description,
|
||||
self.props.icon,
|
||||
self.props.bindable
|
||||
)
|
||||
|
||||
self.pluginAction.Triggered:Connect(self.props.onTriggered)
|
||||
@@ -31,9 +35,12 @@ end
|
||||
local function StudioPluginActionWrapper(props)
|
||||
return e(StudioPluginContext.Consumer, {
|
||||
render = function(plugin)
|
||||
return e(StudioPluginAction, Dictionary.merge(props, {
|
||||
plugin = plugin,
|
||||
}))
|
||||
return e(
|
||||
StudioPluginAction,
|
||||
Dictionary.merge(props, {
|
||||
plugin = plugin,
|
||||
})
|
||||
)
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
local HttpService = game:GetService("HttpService")
|
||||
|
||||
local Rojo = script:FindFirstAncestor("Rojo")
|
||||
local Plugin = Rojo.Plugin
|
||||
local Packages = Rojo.Packages
|
||||
@@ -5,6 +7,7 @@ local Packages = Rojo.Packages
|
||||
local Roact = require(Packages.Roact)
|
||||
|
||||
local Dictionary = require(Plugin.Dictionary)
|
||||
local Theme = require(Plugin.App.Theme)
|
||||
|
||||
local StudioPluginContext = require(script.Parent.StudioPluginContext)
|
||||
|
||||
@@ -29,11 +32,16 @@ function StudioPluginGui:init()
|
||||
self.props.initDockState,
|
||||
self.props.active,
|
||||
self.props.overridePreviousState,
|
||||
floatingSize.X, floatingSize.Y,
|
||||
minimumSize.X, minimumSize.Y
|
||||
floatingSize.X,
|
||||
floatingSize.Y,
|
||||
minimumSize.X,
|
||||
minimumSize.Y
|
||||
)
|
||||
|
||||
local pluginGui = self.props.plugin:CreateDockWidgetPluginGui(self.props.id, dockWidgetPluginGuiInfo)
|
||||
local pluginGui = self.props.plugin:CreateDockWidgetPluginGui(
|
||||
if self.props.isEphemeral then HttpService:GenerateGUID(false) else self.props.id,
|
||||
dockWidgetPluginGuiInfo
|
||||
)
|
||||
|
||||
pluginGui.Name = self.props.id
|
||||
pluginGui.Title = self.props.title
|
||||
@@ -57,13 +65,28 @@ end
|
||||
function StudioPluginGui:render()
|
||||
return e(Roact.Portal, {
|
||||
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
|
||||
|
||||
function StudioPluginGui:didUpdate(lastProps)
|
||||
if self.props.active ~= lastProps.active then
|
||||
-- This is intentionally in didUpdate to make sure the initial active state
|
||||
-- (if the PluginGui is open initially) is preserved.
|
||||
|
||||
-- Studio widgets are very unreliable and sometimes need to be flickered
|
||||
-- in order to force them to render correctly
|
||||
-- This happens within a single frame so it doesn't flicker visibly
|
||||
self.pluginGui.Enabled = self.props.active
|
||||
self.pluginGui.Enabled = not self.props.active
|
||||
self.pluginGui.Enabled = self.props.active
|
||||
end
|
||||
end
|
||||
@@ -75,9 +98,12 @@ end
|
||||
local function StudioPluginGuiWrapper(props)
|
||||
return e(StudioPluginContext.Consumer, {
|
||||
render = function(plugin)
|
||||
return e(StudioPluginGui, Dictionary.merge(props, {
|
||||
plugin = plugin,
|
||||
}))
|
||||
return e(
|
||||
StudioPluginGui,
|
||||
Dictionary.merge(props, {
|
||||
plugin = plugin,
|
||||
})
|
||||
)
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
@@ -18,12 +18,8 @@ StudioToggleButton.defaultProps = {
|
||||
}
|
||||
|
||||
function StudioToggleButton:init()
|
||||
local button = self.props.toolbar:CreateButton(
|
||||
self.props.name,
|
||||
self.props.tooltip,
|
||||
self.props.icon,
|
||||
self.props.text
|
||||
)
|
||||
local button =
|
||||
self.props.toolbar:CreateButton(self.props.name, self.props.tooltip, self.props.icon, self.props.text)
|
||||
|
||||
button.Click:Connect(function()
|
||||
if self.props.onClick then
|
||||
@@ -61,9 +57,12 @@ end
|
||||
local function StudioToggleButtonWrapper(props)
|
||||
return e(StudioToolbarContext.Consumer, {
|
||||
render = function(toolbar)
|
||||
return e(StudioToggleButton, Dictionary.merge(props, {
|
||||
toolbar = toolbar,
|
||||
}))
|
||||
return e(
|
||||
StudioToggleButton,
|
||||
Dictionary.merge(props, {
|
||||
toolbar = toolbar,
|
||||
})
|
||||
)
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
@@ -36,9 +36,12 @@ end
|
||||
local function StudioToolbarWrapper(props)
|
||||
return e(StudioPluginContext.Consumer, {
|
||||
render = function(plugin)
|
||||
return e(StudioToolbar, Dictionary.merge(props, {
|
||||
plugin = plugin,
|
||||
}))
|
||||
return e(
|
||||
StudioToolbar,
|
||||
Dictionary.merge(props, {
|
||||
plugin = plugin,
|
||||
})
|
||||
)
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
@@ -41,10 +41,8 @@ end
|
||||
|
||||
function TextButton:render()
|
||||
return Theme.with(function(theme)
|
||||
local textSize = TextService:GetTextSize(
|
||||
self.props.text, 18, Enum.Font.GothamSemibold,
|
||||
Vector2.new(math.huge, math.huge)
|
||||
)
|
||||
local textSize =
|
||||
TextService:GetTextSize(self.props.text, 18, Enum.Font.GothamSemibold, Vector2.new(math.huge, math.huge))
|
||||
|
||||
local style = self.props.style
|
||||
|
||||
@@ -124,13 +122,19 @@ function TextButton:render()
|
||||
|
||||
Background = style == "Solid" and e(SlicedImage, {
|
||||
slice = Assets.Slices.RoundedBackground,
|
||||
color = bindingUtil.mapLerp(bindingEnabled, theme.Enabled.BackgroundColor, theme.Disabled.BackgroundColor),
|
||||
color = bindingUtil.mapLerp(
|
||||
bindingEnabled,
|
||||
theme.Enabled.BackgroundColor,
|
||||
theme.Disabled.BackgroundColor
|
||||
),
|
||||
transparency = self.props.transparency,
|
||||
|
||||
size = UDim2.new(1, 0, 1, 0),
|
||||
|
||||
zIndex = -2,
|
||||
}),
|
||||
|
||||
Children = Roact.createFragment(self.props[Roact.Children]),
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
107
plugin/src/App/Components/TextInput.lua
Normal file
107
plugin/src/App/Components/TextInput.lua
Normal file
@@ -0,0 +1,107 @@
|
||||
local Rojo = script:FindFirstAncestor("Rojo")
|
||||
local Plugin = Rojo.Plugin
|
||||
local Packages = Rojo.Packages
|
||||
|
||||
local Roact = require(Packages.Roact)
|
||||
local Flipper = require(Packages.Flipper)
|
||||
|
||||
local Theme = require(Plugin.App.Theme)
|
||||
local Assets = require(Plugin.Assets)
|
||||
local bindingUtil = require(Plugin.App.bindingUtil)
|
||||
|
||||
local SlicedImage = require(script.Parent.SlicedImage)
|
||||
|
||||
local SPRING_PROPS = {
|
||||
frequency = 5,
|
||||
dampingRatio = 1,
|
||||
}
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
local TextInput = Roact.Component:extend("TextInput")
|
||||
|
||||
function TextInput:init()
|
||||
self.motor = Flipper.GroupMotor.new({
|
||||
hover = 0,
|
||||
enabled = self.props.enabled and 1 or 0,
|
||||
})
|
||||
self.binding = bindingUtil.fromMotor(self.motor)
|
||||
end
|
||||
|
||||
function TextInput:didUpdate(lastProps)
|
||||
if lastProps.enabled ~= self.props.enabled then
|
||||
self.motor:setGoal({
|
||||
enabled = Flipper.Spring.new(self.props.enabled and 1 or 0),
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
function TextInput:render()
|
||||
return Theme.with(function(theme)
|
||||
theme = theme.TextInput
|
||||
|
||||
local bindingHover = bindingUtil.deriveProperty(self.binding, "hover")
|
||||
local bindingEnabled = bindingUtil.deriveProperty(self.binding, "enabled")
|
||||
|
||||
return e(SlicedImage, {
|
||||
slice = Assets.Slices.RoundedBorder,
|
||||
color = bindingUtil.mapLerp(bindingEnabled, theme.Enabled.BorderColor, theme.Disabled.BorderColor),
|
||||
transparency = self.props.transparency,
|
||||
|
||||
size = self.props.size or UDim2.new(1, 0, 1, 0),
|
||||
position = self.props.position,
|
||||
layoutOrder = self.props.layoutOrder,
|
||||
anchorPoint = self.props.anchorPoint,
|
||||
}, {
|
||||
HoverOverlay = e(SlicedImage, {
|
||||
slice = Assets.Slices.RoundedBackground,
|
||||
color = theme.ActionFillColor,
|
||||
transparency = Roact.joinBindings({
|
||||
hover = bindingHover:map(function(value)
|
||||
return 1 - value
|
||||
end),
|
||||
transparency = self.props.transparency,
|
||||
}):map(function(values)
|
||||
return bindingUtil.blendAlpha({ theme.ActionFillTransparency, values.hover, values.transparency })
|
||||
end),
|
||||
size = UDim2.new(1, 0, 1, 0),
|
||||
zIndex = -1,
|
||||
}),
|
||||
Input = e("TextBox", {
|
||||
BackgroundTransparency = 1,
|
||||
Size = UDim2.fromScale(1, 1),
|
||||
Text = self.props.text,
|
||||
PlaceholderText = self.props.placeholder,
|
||||
Font = Enum.Font.GothamMedium,
|
||||
TextColor3 = bindingUtil.mapLerp(bindingEnabled, theme.Disabled.TextColor, theme.Enabled.TextColor),
|
||||
PlaceholderColor3 = bindingUtil.mapLerp(
|
||||
bindingEnabled,
|
||||
theme.Disabled.PlaceholderColor,
|
||||
theme.Enabled.PlaceholderColor
|
||||
),
|
||||
TextSize = 18,
|
||||
TextEditable = self.props.enabled,
|
||||
ClearTextOnFocus = self.props.clearTextOnFocus,
|
||||
|
||||
[Roact.Event.MouseEnter] = function()
|
||||
self.motor:setGoal({
|
||||
hover = Flipper.Spring.new(1, SPRING_PROPS),
|
||||
})
|
||||
end,
|
||||
|
||||
[Roact.Event.MouseLeave] = function()
|
||||
self.motor:setGoal({
|
||||
hover = Flipper.Spring.new(0, SPRING_PROPS),
|
||||
})
|
||||
end,
|
||||
|
||||
[Roact.Event.FocusLost] = function(rbx)
|
||||
self.props.onEntered(rbx.Text)
|
||||
end,
|
||||
}),
|
||||
Children = Roact.createFragment(self.props[Roact.Children]),
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
return TextInput
|
||||
276
plugin/src/App/Components/Tooltip.lua
Normal file
276
plugin/src/App/Components/Tooltip.lua
Normal file
@@ -0,0 +1,276 @@
|
||||
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.showingPopup = false
|
||||
|
||||
self.destroy = function()
|
||||
self.props.context.removeTip(self.id)
|
||||
self.showingPopup = false
|
||||
end
|
||||
end
|
||||
|
||||
function Trigger:willUnmount()
|
||||
if self.showDelayThread then
|
||||
pcall(task.cancel, self.showDelayThread)
|
||||
end
|
||||
if self.destroy then
|
||||
self.destroy()
|
||||
end
|
||||
end
|
||||
|
||||
function Trigger:didUpdate(prevProps)
|
||||
if prevProps.text ~= self.props.text then
|
||||
-- Any existing popup is now invalid
|
||||
self.props.context.removeTip(self.id)
|
||||
self.showingPopup = false
|
||||
|
||||
-- Let the new text propagate
|
||||
self:managePopup()
|
||||
end
|
||||
end
|
||||
|
||||
function Trigger:isHovering()
|
||||
local rbx = self.ref.current
|
||||
if rbx then
|
||||
local pos = rbx.AbsolutePosition
|
||||
local size = rbx.AbsoluteSize
|
||||
local mousePos = self.mousePos
|
||||
|
||||
return mousePos.X >= pos.X
|
||||
and mousePos.X <= pos.X + size.X
|
||||
and mousePos.Y >= pos.Y
|
||||
and mousePos.Y <= pos.Y + size.Y
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function Trigger:managePopup()
|
||||
if self:isHovering() then
|
||||
if self.showingPopup or self.showDelayThread then
|
||||
-- Don't duplicate popups
|
||||
return
|
||||
end
|
||||
|
||||
self.showDelayThread = task.delay(DELAY, function()
|
||||
self.props.context.addTip(self.id, {
|
||||
Text = self.props.text,
|
||||
Position = self.mousePos,
|
||||
Trigger = self.ref,
|
||||
})
|
||||
self.showDelayThread = nil
|
||||
self.showingPopup = true
|
||||
end)
|
||||
else
|
||||
if self.showDelayThread then
|
||||
pcall(task.cancel, self.showDelayThread)
|
||||
self.showDelayThread = nil
|
||||
end
|
||||
self.props.context.removeTip(self.id)
|
||||
self.showingPopup = false
|
||||
end
|
||||
end
|
||||
|
||||
function Trigger:render()
|
||||
local function recalculate(rbx)
|
||||
local widget = rbx:FindFirstAncestorOfClass("DockWidgetPluginGui")
|
||||
if not widget then
|
||||
return
|
||||
end
|
||||
self.mousePos = widget:GetRelativeMousePosition()
|
||||
|
||||
self:managePopup()
|
||||
end
|
||||
|
||||
return e("Frame", {
|
||||
Size = UDim2.fromScale(1, 1),
|
||||
BackgroundTransparency = 1,
|
||||
ZIndex = self.props.zIndex or 100,
|
||||
[Roact.Ref] = self.ref,
|
||||
|
||||
[Roact.Change.AbsolutePosition] = recalculate,
|
||||
[Roact.Change.AbsoluteSize] = recalculate,
|
||||
[Roact.Event.MouseMoved] = recalculate,
|
||||
[Roact.Event.MouseLeave] = recalculate,
|
||||
[Roact.Event.MouseEnter] = recalculate,
|
||||
})
|
||||
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,
|
||||
}
|
||||
@@ -24,9 +24,7 @@ function TouchRipple:init()
|
||||
})
|
||||
self.binding = bindingUtil.fromMotor(self.motor)
|
||||
|
||||
self.position, self.setPosition = Roact.createBinding(
|
||||
Vector2.new(0, 0)
|
||||
)
|
||||
self.position, self.setPosition = Roact.createBinding(Vector2.new(0, 0))
|
||||
end
|
||||
|
||||
function TouchRipple:reset()
|
||||
@@ -43,10 +41,7 @@ function TouchRipple:calculateRadius(position)
|
||||
local container = self.ref.current
|
||||
|
||||
if container then
|
||||
local corner = Vector2.new(
|
||||
math.floor((1 - position.X) + 0.5),
|
||||
math.floor((1 - position.Y) + 0.5)
|
||||
)
|
||||
local corner = Vector2.new(math.floor((1 - position.X) + 0.5), math.floor((1 - position.Y) + 0.5))
|
||||
|
||||
local size = container.AbsoluteSize
|
||||
local ratio = size / math.min(size.X, size.Y)
|
||||
@@ -93,10 +88,7 @@ function TouchRipple:render()
|
||||
input:GetPropertyChangedSignal("UserInputState"):Connect(function()
|
||||
local userInputState = input.UserInputState
|
||||
|
||||
if
|
||||
userInputState == Enum.UserInputState.Cancel
|
||||
or userInputState == Enum.UserInputState.End
|
||||
then
|
||||
if userInputState == Enum.UserInputState.Cancel or userInputState == Enum.UserInputState.End then
|
||||
self.motor:setGoal({
|
||||
opacity = Flipper.Spring.new(0, {
|
||||
frequency = 5,
|
||||
@@ -127,8 +119,10 @@ function TouchRipple:render()
|
||||
local containerAspect = containerSize.X / containerSize.Y
|
||||
|
||||
return UDim2.new(
|
||||
currentSize / math.max(containerAspect, 1), 0,
|
||||
currentSize * math.min(containerAspect, 1), 0
|
||||
currentSize / math.max(containerAspect, 1),
|
||||
0,
|
||||
currentSize * math.min(containerAspect, 1),
|
||||
0
|
||||
)
|
||||
end
|
||||
end),
|
||||
|
||||
168
plugin/src/App/Components/VirtualScroller.lua
Normal file
168
plugin/src/App/Components/VirtualScroller.lua
Normal file
@@ -0,0 +1,168 @@
|
||||
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:didUpdate(previousProps)
|
||||
if self.props.count ~= previousProps.count then
|
||||
-- Items have changed, so we need to refresh
|
||||
self:refresh()
|
||||
end
|
||||
end
|
||||
|
||||
function VirtualScroller:render()
|
||||
local props, state = self.props, self.state
|
||||
|
||||
local items = {}
|
||||
for i = state.Start, state.End do
|
||||
local content = props.render(i)
|
||||
if content == nil then
|
||||
continue
|
||||
end
|
||||
|
||||
items["Item" .. i] = e("Frame", {
|
||||
LayoutOrder = i,
|
||||
Size = props.getHeightBinding(i):map(function(height)
|
||||
return UDim2.new(1, 0, 0, height)
|
||||
end),
|
||||
BackgroundTransparency = 1,
|
||||
}, content)
|
||||
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
|
||||
@@ -7,6 +7,7 @@ local Packages = Rojo.Packages
|
||||
|
||||
local Roact = require(Packages.Roact)
|
||||
local Flipper = require(Packages.Flipper)
|
||||
local Log = require(Packages.Log)
|
||||
|
||||
local bindingUtil = require(script.Parent.bindingUtil)
|
||||
|
||||
@@ -14,6 +15,7 @@ local Theme = require(Plugin.App.Theme)
|
||||
local Assets = require(Plugin.Assets)
|
||||
|
||||
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
|
||||
local TextButton = require(Plugin.App.Components.TextButton)
|
||||
|
||||
local baseClock = DateTime.now().UnixTimestampMillis
|
||||
|
||||
@@ -28,37 +30,31 @@ function Notification:init()
|
||||
self.lifetime = self.props.timeout
|
||||
|
||||
self.motor:onStep(function(value)
|
||||
if value <= 0 then
|
||||
if self.props.onClose then
|
||||
self.props.onClose()
|
||||
end
|
||||
if value <= 0 and self.props.onClose then
|
||||
self.props.onClose()
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
function Notification:dismiss()
|
||||
self.motor:setGoal(
|
||||
Flipper.Spring.new(0, {
|
||||
frequency = 5,
|
||||
dampingRatio = 1,
|
||||
})
|
||||
)
|
||||
self.motor:setGoal(Flipper.Spring.new(0, {
|
||||
frequency = 5,
|
||||
dampingRatio = 1,
|
||||
}))
|
||||
end
|
||||
|
||||
function Notification:didMount()
|
||||
self.motor:setGoal(
|
||||
Flipper.Spring.new(1, {
|
||||
frequency = 3,
|
||||
dampingRatio = 1,
|
||||
})
|
||||
)
|
||||
self.motor:setGoal(Flipper.Spring.new(1, {
|
||||
frequency = 3,
|
||||
dampingRatio = 1,
|
||||
}))
|
||||
|
||||
self.props.soundPlayer:play(Assets.Sounds.Notification)
|
||||
|
||||
self.timeout = task.spawn(function()
|
||||
local clock = os.clock()
|
||||
local seen = false
|
||||
while task.wait(1/10) do
|
||||
while task.wait(1 / 10) do
|
||||
local now = os.clock()
|
||||
local dt = now - clock
|
||||
clock = now
|
||||
@@ -86,23 +82,51 @@ function Notification:willUnmount()
|
||||
end
|
||||
|
||||
function Notification:render()
|
||||
local time = DateTime.fromUnixTimestampMillis(self.props.timestamp)
|
||||
|
||||
local textBounds = TextService:GetTextSize(
|
||||
self.props.text,
|
||||
15,
|
||||
Enum.Font.GothamSemibold,
|
||||
Vector2.new(350, 700)
|
||||
)
|
||||
|
||||
local transparency = self.binding:map(function(value)
|
||||
return 1 - value
|
||||
end)
|
||||
|
||||
local textBounds = TextService:GetTextSize(self.props.text, 15, Enum.Font.GothamMedium, Vector2.new(350, 700))
|
||||
|
||||
local actionButtons = {}
|
||||
local buttonsX = 0
|
||||
if self.props.actions then
|
||||
local count = 0
|
||||
for key, action in self.props.actions do
|
||||
actionButtons[key] = e(TextButton, {
|
||||
text = action.text,
|
||||
style = action.style,
|
||||
onClick = function()
|
||||
local success, err = pcall(action.onClick, self)
|
||||
if not success then
|
||||
Log.warn("Error in notification action: " .. tostring(err))
|
||||
end
|
||||
end,
|
||||
layoutOrder = -action.layoutOrder,
|
||||
transparency = transparency,
|
||||
})
|
||||
|
||||
buttonsX += TextService:GetTextSize(
|
||||
action.text,
|
||||
18,
|
||||
Enum.Font.GothamMedium,
|
||||
Vector2.new(math.huge, math.huge)
|
||||
).X + 30
|
||||
|
||||
count += 1
|
||||
end
|
||||
|
||||
buttonsX += (count - 1) * 5
|
||||
end
|
||||
|
||||
local paddingY, logoSize = 20, 32
|
||||
local actionsY = if self.props.actions then 35 else 0
|
||||
local contentX = math.max(textBounds.X, buttonsX)
|
||||
|
||||
local size = self.binding:map(function(value)
|
||||
return UDim2.fromOffset(
|
||||
(35+40+textBounds.X)*value,
|
||||
math.max(14+20+textBounds.Y, 32+20)
|
||||
(35 + 40 + contentX) * value,
|
||||
5 + actionsY + paddingY + math.max(logoSize, textBounds.Y)
|
||||
)
|
||||
end)
|
||||
|
||||
@@ -122,22 +146,22 @@ function Notification:render()
|
||||
transparency = transparency,
|
||||
size = UDim2.new(1, 0, 1, 0),
|
||||
}, {
|
||||
TextContainer = e("Frame", {
|
||||
Size = UDim2.new(0, 35+textBounds.X, 1, -20),
|
||||
Position = UDim2.new(0, 0, 0, 10),
|
||||
BackgroundTransparency = 1
|
||||
Contents = e("Frame", {
|
||||
Size = UDim2.new(0, 35 + contentX, 1, -paddingY),
|
||||
Position = UDim2.new(0, 0, 0, paddingY / 2),
|
||||
BackgroundTransparency = 1,
|
||||
}, {
|
||||
Logo = e("ImageLabel", {
|
||||
ImageTransparency = transparency,
|
||||
Image = Assets.Images.PluginButton,
|
||||
BackgroundTransparency = 1,
|
||||
Size = UDim2.new(0, 32, 0, 32),
|
||||
Position = UDim2.new(0, 0, 0.5, 0),
|
||||
AnchorPoint = Vector2.new(0, 0.5),
|
||||
Size = UDim2.new(0, logoSize, 0, logoSize),
|
||||
Position = UDim2.new(0, 0, 0, 0),
|
||||
AnchorPoint = Vector2.new(0, 0),
|
||||
}),
|
||||
Info = e("TextLabel", {
|
||||
Text = self.props.text,
|
||||
Font = Enum.Font.GothamSemibold,
|
||||
Font = Enum.Font.GothamMedium,
|
||||
TextSize = 15,
|
||||
TextColor3 = theme.Notification.InfoColor,
|
||||
TextTransparency = transparency,
|
||||
@@ -150,27 +174,30 @@ function Notification:render()
|
||||
LayoutOrder = 1,
|
||||
BackgroundTransparency = 1,
|
||||
}),
|
||||
Time = e("TextLabel", {
|
||||
Text = time:FormatLocalTime("LTS", "en-us"),
|
||||
Font = Enum.Font.Code,
|
||||
TextSize = 12,
|
||||
TextColor3 = theme.Notification.InfoColor,
|
||||
TextTransparency = transparency,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
|
||||
Size = UDim2.new(1, -35, 0, 14),
|
||||
Position = UDim2.new(0, 35, 1, -14),
|
||||
|
||||
LayoutOrder = 1,
|
||||
BackgroundTransparency = 1,
|
||||
}),
|
||||
Actions = if self.props.actions
|
||||
then e("Frame", {
|
||||
Size = UDim2.new(1, -40, 0, 35),
|
||||
Position = UDim2.new(1, 0, 1, 0),
|
||||
AnchorPoint = Vector2.new(1, 1),
|
||||
BackgroundTransparency = 1,
|
||||
}, {
|
||||
Layout = e("UIListLayout", {
|
||||
FillDirection = Enum.FillDirection.Horizontal,
|
||||
HorizontalAlignment = Enum.HorizontalAlignment.Right,
|
||||
VerticalAlignment = Enum.VerticalAlignment.Center,
|
||||
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||
Padding = UDim.new(0, 5),
|
||||
}),
|
||||
Buttons = Roact.createFragment(actionButtons),
|
||||
})
|
||||
else nil,
|
||||
}),
|
||||
|
||||
Padding = e("UIPadding", {
|
||||
PaddingLeft = UDim.new(0, 17),
|
||||
PaddingRight = UDim.new(0, 15),
|
||||
}),
|
||||
})
|
||||
}),
|
||||
})
|
||||
end)
|
||||
end
|
||||
@@ -180,15 +207,16 @@ local Notifications = Roact.Component:extend("Notifications")
|
||||
function Notifications:render()
|
||||
local notifs = {}
|
||||
|
||||
for index, notif in ipairs(self.props.notifications) do
|
||||
notifs[notif] = e(Notification, {
|
||||
for id, notif in self.props.notifications do
|
||||
notifs["NotifID_" .. id] = e(Notification, {
|
||||
soundPlayer = self.props.soundPlayer,
|
||||
text = notif.text,
|
||||
timestamp = notif.timestamp,
|
||||
timeout = notif.timeout,
|
||||
actions = notif.actions,
|
||||
layoutOrder = (notif.timestamp - baseClock),
|
||||
onClose = function()
|
||||
self.props.onClose(index)
|
||||
self.props.onClose(id)
|
||||
end,
|
||||
})
|
||||
end
|
||||
|
||||
@@ -15,7 +15,7 @@ local Page = Roact.Component:extend("Page")
|
||||
|
||||
function Page:init()
|
||||
self:setState({
|
||||
rendered = self.props.active
|
||||
rendered = self.props.active,
|
||||
})
|
||||
|
||||
self.motor = Flipper.SingleMotor.new(self.props.active and 1 or 0)
|
||||
@@ -51,20 +51,21 @@ function Page:render()
|
||||
Size = UDim2.new(1, 0, 1, 0),
|
||||
BackgroundTransparency = 1,
|
||||
}, {
|
||||
Component = e(self.props.component, Dictionary.merge(self.props, {
|
||||
transparency = transparency,
|
||||
}))
|
||||
Component = e(
|
||||
self.props.component,
|
||||
Dictionary.merge(self.props, {
|
||||
transparency = transparency,
|
||||
})
|
||||
),
|
||||
})
|
||||
end
|
||||
|
||||
function Page:didUpdate(lastProps)
|
||||
if self.props.active ~= lastProps.active then
|
||||
self.motor:setGoal(
|
||||
Flipper.Spring.new(self.props.active and 1 or 0, {
|
||||
frequency = 6,
|
||||
dampingRatio = 1,
|
||||
})
|
||||
)
|
||||
self.motor:setGoal(Flipper.Spring.new(self.props.active and 1 or 0, {
|
||||
frequency = 6,
|
||||
dampingRatio = 1,
|
||||
}))
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
209
plugin/src/App/StatusPages/Confirming.lua
Normal file
209
plugin/src/App/StatusPages/Confirming.lua
Normal file
@@ -0,0 +1,209 @@
|
||||
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 StringDiffVisualizer = require(Plugin.App.Components.StringDiffVisualizer)
|
||||
|
||||
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))
|
||||
|
||||
self:setState({
|
||||
showingSourceDiff = false,
|
||||
oldSource = "",
|
||||
newSource = "",
|
||||
})
|
||||
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,
|
||||
|
||||
changeListHeaders = { "Property", "Current", "Incoming" },
|
||||
patch = self.props.confirmData.patch,
|
||||
instanceMap = self.props.confirmData.instanceMap,
|
||||
|
||||
showSourceDiff = function(oldSource: string, newSource: string)
|
||||
self:setState({
|
||||
showingSourceDiff = true,
|
||||
oldSource = oldSource,
|
||||
newSource = newSource,
|
||||
})
|
||||
end,
|
||||
}),
|
||||
|
||||
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),
|
||||
}),
|
||||
|
||||
SourceDiff = e(StudioPluginGui, {
|
||||
id = "Rojo_ConfirmingSourceDiff",
|
||||
title = "Source diff",
|
||||
active = self.state.showingSourceDiff,
|
||||
isEphemeral = true,
|
||||
|
||||
initDockState = Enum.InitialDockState.Float,
|
||||
overridePreviousState = true,
|
||||
floatingSize = Vector2.new(500, 350),
|
||||
minimumSize = Vector2.new(400, 250),
|
||||
|
||||
zIndexBehavior = Enum.ZIndexBehavior.Sibling,
|
||||
|
||||
onClose = function()
|
||||
self:setState({
|
||||
showingSourceDiff = false,
|
||||
})
|
||||
end,
|
||||
}, {
|
||||
TooltipsProvider = e(Tooltip.Provider, nil, {
|
||||
Tooltips = e(Tooltip.Container, nil),
|
||||
Content = e("Frame", {
|
||||
Size = UDim2.fromScale(1, 1),
|
||||
BackgroundTransparency = 1,
|
||||
}, {
|
||||
e(StringDiffVisualizer, {
|
||||
size = UDim2.new(1, -10, 1, -10),
|
||||
position = UDim2.new(0, 5, 0, 5),
|
||||
anchorPoint = Vector2.new(0, 0),
|
||||
transparency = self.props.transparency,
|
||||
|
||||
oldText = self.state.oldSource,
|
||||
newText = self.state.newSource,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
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,
|
||||
isEphemeral = true,
|
||||
|
||||
initDockState = Enum.InitialDockState.Float,
|
||||
overridePreviousState = false,
|
||||
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
|
||||
@@ -3,17 +3,35 @@ local Plugin = Rojo.Plugin
|
||||
local Packages = Rojo.Packages
|
||||
|
||||
local Roact = require(Packages.Roact)
|
||||
local Flipper = require(Packages.Flipper)
|
||||
|
||||
local bindingUtil = require(Plugin.App.bindingUtil)
|
||||
local Theme = require(Plugin.App.Theme)
|
||||
local Assets = require(Plugin.Assets)
|
||||
local PatchSet = require(Plugin.PatchSet)
|
||||
|
||||
local StudioPluginGui = require(Plugin.App.Components.Studio.StudioPluginGui)
|
||||
local Header = require(Plugin.App.Components.Header)
|
||||
local IconButton = require(Plugin.App.Components.IconButton)
|
||||
local TextButton = require(Plugin.App.Components.TextButton)
|
||||
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
|
||||
local Tooltip = require(Plugin.App.Components.Tooltip)
|
||||
local PatchVisualizer = require(Plugin.App.Components.PatchVisualizer)
|
||||
local StringDiffVisualizer = require(Plugin.App.Components.StringDiffVisualizer)
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
local AGE_UNITS = { {31556909, "year"}, {2629743, "month"}, {604800, "week"}, {86400, "day"}, {3600, "hour"}, {60, "minute"}, }
|
||||
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"
|
||||
@@ -21,11 +39,11 @@ function timeSinceText(elapsed: number): string
|
||||
|
||||
local ageText = string.format("%d seconds ago", elapsed)
|
||||
|
||||
for _,UnitData in ipairs(AGE_UNITS) do
|
||||
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 "")
|
||||
local c = math.floor(elapsed / UnitSeconds)
|
||||
ageText = string.format("%d %s%s ago", c, UnitName, c > 1 and "s" or "")
|
||||
break
|
||||
end
|
||||
end
|
||||
@@ -33,6 +51,58 @@ function timeSinceText(elapsed: number): string
|
||||
return ageText
|
||||
end
|
||||
|
||||
local ChangesDrawer = Roact.Component:extend("ChangesDrawer")
|
||||
|
||||
function ChangesDrawer:init()
|
||||
-- Hold onto the serve session during the lifecycle of this component
|
||||
-- so that it can still render during the fade out after disconnecting
|
||||
self.serveSession = self.props.serveSession
|
||||
end
|
||||
|
||||
function ChangesDrawer:render()
|
||||
if self.props.rendered == false or self.serveSession == nil then
|
||||
return nil
|
||||
end
|
||||
|
||||
return Theme.with(function(theme)
|
||||
return e(BorderedContainer, {
|
||||
transparency = self.props.transparency,
|
||||
size = self.props.height:map(function(y)
|
||||
return UDim2.new(1, 0, y, -220 * y)
|
||||
end),
|
||||
position = UDim2.new(0, 0, 1, 0),
|
||||
anchorPoint = Vector2.new(0, 1),
|
||||
layoutOrder = self.props.layoutOrder,
|
||||
}, {
|
||||
Close = e(IconButton, {
|
||||
icon = Assets.Images.Icons.Close,
|
||||
iconSize = 24,
|
||||
color = theme.ConnectionDetails.DisconnectColor,
|
||||
transparency = self.props.transparency,
|
||||
|
||||
position = UDim2.new(1, 0, 0, 0),
|
||||
anchorPoint = Vector2.new(1, 0),
|
||||
|
||||
onClick = self.props.onClose,
|
||||
}, {
|
||||
Tip = e(Tooltip.Trigger, {
|
||||
text = "Close the patch visualizer",
|
||||
}),
|
||||
}),
|
||||
|
||||
PatchVisualizer = e(PatchVisualizer, {
|
||||
size = UDim2.new(1, 0, 1, 0),
|
||||
transparency = self.props.transparency,
|
||||
layoutOrder = 3,
|
||||
|
||||
patchTree = self.props.patchTree,
|
||||
|
||||
showSourceDiff = self.props.showSourceDiff,
|
||||
}),
|
||||
})
|
||||
end)
|
||||
end
|
||||
|
||||
local function ConnectionDetails(props)
|
||||
return Theme.with(function(theme)
|
||||
return e(BorderedContainer, {
|
||||
@@ -42,7 +112,7 @@ local function ConnectionDetails(props)
|
||||
}, {
|
||||
TextContainer = e("Frame", {
|
||||
Size = UDim2.new(1, 0, 1, 0),
|
||||
BackgroundTransparency = 1
|
||||
BackgroundTransparency = 1,
|
||||
}, {
|
||||
ProjectName = e("TextLabel", {
|
||||
Text = props.projectName,
|
||||
@@ -80,18 +150,6 @@ local function ConnectionDetails(props)
|
||||
}),
|
||||
}),
|
||||
|
||||
Disconnect = e(IconButton, {
|
||||
icon = Assets.Images.Icons.Close,
|
||||
iconSize = 24,
|
||||
color = theme.ConnectionDetails.DisconnectColor,
|
||||
transparency = props.transparency,
|
||||
|
||||
position = UDim2.new(1, 0, 0.5, 0),
|
||||
anchorPoint = Vector2.new(1, 0.5),
|
||||
|
||||
onClick = props.onDisconnect,
|
||||
}),
|
||||
|
||||
Padding = e("UIPadding", {
|
||||
PaddingLeft = UDim.new(0, 17),
|
||||
PaddingRight = UDim.new(0, 15),
|
||||
@@ -102,9 +160,124 @@ end
|
||||
|
||||
local ConnectedPage = Roact.Component:extend("ConnectedPage")
|
||||
|
||||
function ConnectedPage:getChangeInfoText()
|
||||
local patchData = self.props.patchData
|
||||
if patchData == nil then
|
||||
return ""
|
||||
end
|
||||
|
||||
local elapsed = os.time() - patchData.timestamp
|
||||
local unapplied = PatchSet.countChanges(patchData.unapplied)
|
||||
|
||||
return "<i>Synced "
|
||||
.. timeSinceText(elapsed)
|
||||
.. (if unapplied > 0
|
||||
then string.format(
|
||||
', <font color="#FF8E3C">but %d change%s failed to apply</font>',
|
||||
unapplied,
|
||||
unapplied == 1 and "" or "s"
|
||||
)
|
||||
else "")
|
||||
.. "</i>"
|
||||
end
|
||||
|
||||
function ConnectedPage:startChangeInfoTextUpdater()
|
||||
-- Cancel any existing updater
|
||||
self:stopChangeInfoTextUpdater()
|
||||
|
||||
-- Start a new updater
|
||||
self.changeInfoTextUpdater = task.defer(function()
|
||||
while true do
|
||||
if self.state.hoveringChangeInfo then
|
||||
self.setChangeInfoText("<u>" .. self:getChangeInfoText() .. "</u>")
|
||||
else
|
||||
self.setChangeInfoText(self:getChangeInfoText())
|
||||
end
|
||||
|
||||
local elapsed = os.time() - self.props.patchData.timestamp
|
||||
local updateInterval = 1
|
||||
|
||||
-- Update timestamp text as frequently as currently needed
|
||||
for _, UnitData in ipairs(AGE_UNITS) do
|
||||
local UnitSeconds = UnitData[1]
|
||||
if elapsed > UnitSeconds then
|
||||
updateInterval = UnitSeconds
|
||||
break
|
||||
end
|
||||
end
|
||||
|
||||
task.wait(updateInterval)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
function ConnectedPage:stopChangeInfoTextUpdater()
|
||||
if self.changeInfoTextUpdater then
|
||||
task.cancel(self.changeInfoTextUpdater)
|
||||
self.changeInfoTextUpdater = nil
|
||||
end
|
||||
end
|
||||
|
||||
function ConnectedPage:init()
|
||||
self.changeDrawerMotor = Flipper.SingleMotor.new(0)
|
||||
self.changeDrawerHeight = bindingUtil.fromMotor(self.changeDrawerMotor)
|
||||
|
||||
self.changeDrawerMotor:onStep(function(value)
|
||||
local renderChanges = value > 0.05
|
||||
|
||||
self:setState(function(state)
|
||||
if state.renderChanges == renderChanges then
|
||||
return nil
|
||||
end
|
||||
|
||||
return {
|
||||
renderChanges = renderChanges,
|
||||
}
|
||||
end)
|
||||
end)
|
||||
|
||||
self:setState({
|
||||
renderChanges = false,
|
||||
hoveringChangeInfo = false,
|
||||
showingSourceDiff = false,
|
||||
oldSource = "",
|
||||
newSource = "",
|
||||
})
|
||||
|
||||
self.changeInfoText, self.setChangeInfoText = Roact.createBinding("")
|
||||
|
||||
self:startChangeInfoTextUpdater()
|
||||
end
|
||||
|
||||
function ConnectedPage:willUnmount()
|
||||
self:stopChangeInfoTextUpdater()
|
||||
end
|
||||
|
||||
function ConnectedPage:didUpdate(previousProps)
|
||||
if self.props.patchData.timestamp ~= previousProps.patchData.timestamp then
|
||||
-- New patch recieved
|
||||
self:startChangeInfoTextUpdater()
|
||||
self:setState({
|
||||
showingSourceDiff = false,
|
||||
})
|
||||
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,
|
||||
@@ -119,15 +292,46 @@ function ConnectedPage:render()
|
||||
onDisconnect = self.props.onDisconnect,
|
||||
}),
|
||||
|
||||
Info = e("TextLabel", {
|
||||
Text = self.props.patchInfo:map(function(info)
|
||||
return string.format(
|
||||
"<i>Synced %d change%s %s</i>",
|
||||
info.changes,
|
||||
info.changes == 1 and "" or "s",
|
||||
timeSinceText(os.time() - info.timestamp)
|
||||
)
|
||||
end),
|
||||
Buttons = e("Frame", {
|
||||
Size = UDim2.new(1, 0, 0, 34),
|
||||
LayoutOrder = 3,
|
||||
BackgroundTransparency = 1,
|
||||
ZIndex = 2,
|
||||
}, {
|
||||
Settings = e(TextButton, {
|
||||
text = "Settings",
|
||||
style = "Bordered",
|
||||
transparency = self.props.transparency,
|
||||
layoutOrder = 1,
|
||||
onClick = self.props.onNavigateSettings,
|
||||
}, {
|
||||
Tip = e(Tooltip.Trigger, {
|
||||
text = "View and modify plugin settings",
|
||||
}),
|
||||
}),
|
||||
|
||||
Disconnect = e(TextButton, {
|
||||
text = "Disconnect",
|
||||
style = "Solid",
|
||||
transparency = self.props.transparency,
|
||||
layoutOrder = 2,
|
||||
onClick = self.props.onDisconnect,
|
||||
}, {
|
||||
Tip = e(Tooltip.Trigger, {
|
||||
text = "Disconnect from the Rojo sync server",
|
||||
}),
|
||||
}),
|
||||
|
||||
Layout = e("UIListLayout", {
|
||||
HorizontalAlignment = Enum.HorizontalAlignment.Right,
|
||||
FillDirection = Enum.FillDirection.Horizontal,
|
||||
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||
Padding = UDim.new(0, 10),
|
||||
}),
|
||||
}),
|
||||
|
||||
ChangeInfo = e("TextButton", {
|
||||
Text = self.changeInfoText,
|
||||
Font = Enum.Font.Gotham,
|
||||
TextSize = 14,
|
||||
TextWrapped = true,
|
||||
@@ -139,20 +343,102 @@ function ConnectedPage:render()
|
||||
|
||||
Size = UDim2.new(1, 0, 0, 28),
|
||||
|
||||
LayoutOrder = 3,
|
||||
LayoutOrder = 4,
|
||||
BackgroundTransparency = 1,
|
||||
|
||||
[Roact.Event.MouseEnter] = function()
|
||||
self:setState({
|
||||
hoveringChangeInfo = true,
|
||||
})
|
||||
self.setChangeInfoText("<u>" .. self:getChangeInfoText() .. "</u>")
|
||||
end,
|
||||
|
||||
[Roact.Event.MouseLeave] = function()
|
||||
self:setState({
|
||||
hoveringChangeInfo = false,
|
||||
})
|
||||
self.setChangeInfoText(self:getChangeInfoText())
|
||||
end,
|
||||
|
||||
[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,
|
||||
}, {
|
||||
Tooltip = e(Tooltip.Trigger, {
|
||||
text = if self.state.renderChanges then "Hide the changes" else "View the changes",
|
||||
}),
|
||||
}),
|
||||
|
||||
Layout = e("UIListLayout", {
|
||||
VerticalAlignment = Enum.VerticalAlignment.Center,
|
||||
FillDirection = Enum.FillDirection.Vertical,
|
||||
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||
Padding = UDim.new(0, 10),
|
||||
ChangesDrawer = e(ChangesDrawer, {
|
||||
rendered = self.state.renderChanges,
|
||||
transparency = self.props.transparency,
|
||||
patchTree = self.props.patchTree,
|
||||
serveSession = self.props.serveSession,
|
||||
height = self.changeDrawerHeight,
|
||||
layoutOrder = 5,
|
||||
|
||||
showSourceDiff = function(oldSource: string, newSource: string)
|
||||
self:setState({
|
||||
showingSourceDiff = true,
|
||||
oldSource = oldSource,
|
||||
newSource = newSource,
|
||||
})
|
||||
end,
|
||||
|
||||
onClose = function()
|
||||
self.changeDrawerMotor:setGoal(Flipper.Spring.new(0, {
|
||||
frequency = 4,
|
||||
dampingRatio = 1,
|
||||
}))
|
||||
end,
|
||||
}),
|
||||
|
||||
Padding = e("UIPadding", {
|
||||
PaddingLeft = UDim.new(0, 20),
|
||||
PaddingRight = UDim.new(0, 20),
|
||||
SourceDiff = e(StudioPluginGui, {
|
||||
id = "Rojo_ConnectedSourceDiff",
|
||||
title = "Source diff",
|
||||
active = self.state.showingSourceDiff,
|
||||
isEphemeral = true,
|
||||
|
||||
initDockState = Enum.InitialDockState.Float,
|
||||
overridePreviousState = false,
|
||||
floatingSize = Vector2.new(500, 350),
|
||||
minimumSize = Vector2.new(400, 250),
|
||||
|
||||
zIndexBehavior = Enum.ZIndexBehavior.Sibling,
|
||||
|
||||
onClose = function()
|
||||
self:setState({
|
||||
showingSourceDiff = false,
|
||||
})
|
||||
end,
|
||||
}, {
|
||||
TooltipsProvider = e(Tooltip.Provider, nil, {
|
||||
Tooltips = e(Tooltip.Container, nil),
|
||||
Content = e("Frame", {
|
||||
Size = UDim2.fromScale(1, 1),
|
||||
BackgroundTransparency = 1,
|
||||
}, {
|
||||
e(StringDiffVisualizer, {
|
||||
size = UDim2.new(1, -10, 1, -10),
|
||||
position = UDim2.new(0, 5, 0, 5),
|
||||
anchorPoint = Vector2.new(0, 0),
|
||||
transparency = self.props.transparency,
|
||||
|
||||
oldText = self.state.oldSource,
|
||||
newText = self.state.newSource,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
end)
|
||||
|
||||
@@ -11,6 +11,7 @@ local Theme = require(Plugin.App.Theme)
|
||||
local TextButton = require(Plugin.App.Components.TextButton)
|
||||
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
|
||||
local ScrollingFrame = require(Plugin.App.Components.ScrollingFrame)
|
||||
local Tooltip = require(Plugin.App.Components.Tooltip)
|
||||
|
||||
local e = Roact.createElement
|
||||
|
||||
@@ -49,7 +50,9 @@ function Error:render()
|
||||
local containerSize = object.AbsoluteSize - ERROR_PADDING * 2
|
||||
|
||||
local textBounds = TextService:GetTextSize(
|
||||
self.props.errorMessage, 16, Enum.Font.Code,
|
||||
self.props.errorMessage,
|
||||
16,
|
||||
Enum.Font.Code,
|
||||
Vector2.new(containerSize.X, math.huge)
|
||||
)
|
||||
|
||||
@@ -59,12 +62,13 @@ function Error:render()
|
||||
ErrorMessage = Theme.with(function(theme)
|
||||
return e("TextBox", {
|
||||
[Roact.Event.InputBegan] = function(rbx, input)
|
||||
if input.UserInputType ~= Enum.UserInputType.MouseButton1 then return end
|
||||
if input.UserInputType ~= Enum.UserInputType.MouseButton1 then
|
||||
return
|
||||
end
|
||||
rbx.SelectionStart = 0
|
||||
rbx.CursorPosition = #rbx.Text+1
|
||||
rbx.CursorPosition = #rbx.Text + 1
|
||||
end,
|
||||
|
||||
|
||||
Text = self.props.errorMessage,
|
||||
TextEditable = false,
|
||||
Font = Enum.Font.Code,
|
||||
@@ -123,6 +127,10 @@ function ErrorPage:render()
|
||||
transparency = self.props.transparency,
|
||||
layoutOrder = 1,
|
||||
onClick = self.props.onClose,
|
||||
}, {
|
||||
Tip = e(Tooltip.Trigger, {
|
||||
text = "Dismiss message",
|
||||
}),
|
||||
}),
|
||||
|
||||
Layout = e("UIListLayout", {
|
||||
|
||||
@@ -10,6 +10,7 @@ local Theme = require(Plugin.App.Theme)
|
||||
local BorderedContainer = require(Plugin.App.Components.BorderedContainer)
|
||||
local TextButton = require(Plugin.App.Components.TextButton)
|
||||
local Header = require(Plugin.App.Components.Header)
|
||||
local Tooltip = require(Plugin.App.Components.Tooltip)
|
||||
|
||||
local PORT_WIDTH = 74
|
||||
local DIVIDER_WIDTH = 1
|
||||
@@ -45,7 +46,7 @@ local function AddressEntry(props)
|
||||
if props.onHostChange ~= nil then
|
||||
props.onHostChange(object.Text)
|
||||
end
|
||||
end
|
||||
end,
|
||||
}),
|
||||
|
||||
Port = e("TextBox", {
|
||||
@@ -109,6 +110,7 @@ function NotConnectedPage:render()
|
||||
Size = UDim2.new(1, 0, 0, 34),
|
||||
LayoutOrder = 3,
|
||||
BackgroundTransparency = 1,
|
||||
ZIndex = 2,
|
||||
}, {
|
||||
Settings = e(TextButton, {
|
||||
text = "Settings",
|
||||
@@ -116,6 +118,10 @@ function NotConnectedPage:render()
|
||||
transparency = self.props.transparency,
|
||||
layoutOrder = 1,
|
||||
onClick = self.props.onNavigateSettings,
|
||||
}, {
|
||||
Tip = e(Tooltip.Trigger, {
|
||||
text = "View and modify plugin settings",
|
||||
}),
|
||||
}),
|
||||
|
||||
Connect = e(TextButton, {
|
||||
@@ -124,6 +130,10 @@ function NotConnectedPage:render()
|
||||
transparency = self.props.transparency,
|
||||
layoutOrder = 2,
|
||||
onClick = self.props.onConnect,
|
||||
}, {
|
||||
Tip = e(Tooltip.Trigger, {
|
||||
text = "Connect to a Rojo sync server",
|
||||
}),
|
||||
}),
|
||||
|
||||
Layout = e("UIListLayout", {
|
||||
|
||||
@@ -7,9 +7,12 @@ 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
|
||||
|
||||
@@ -29,6 +32,7 @@ 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.inputSize, self.setInputSize = Roact.createBinding(Vector2.new(0, 0))
|
||||
|
||||
self:setState({
|
||||
setting = Settings:get(self.props.id),
|
||||
@@ -54,21 +58,62 @@ function Setting:render()
|
||||
return UDim2.new(1, 0, 0, 20 + value.Y + 20)
|
||||
end),
|
||||
LayoutOrder = self.props.layoutOrder,
|
||||
ZIndex = -self.props.layoutOrder,
|
||||
BackgroundTransparency = 1,
|
||||
Visible = self.props.visible,
|
||||
|
||||
[Roact.Change.AbsoluteSize] = function(object)
|
||||
self.setContainerSize(object.AbsoluteSize)
|
||||
end,
|
||||
}, {
|
||||
Checkbox = 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,
|
||||
RightAligned = Roact.createElement("Frame", {
|
||||
BackgroundTransparency = 1,
|
||||
Size = UDim2.new(1, 0, 1, 0),
|
||||
}, {
|
||||
Layout = e("UIListLayout", {
|
||||
VerticalAlignment = Enum.VerticalAlignment.Center,
|
||||
HorizontalAlignment = Enum.HorizontalAlignment.Right,
|
||||
FillDirection = Enum.FillDirection.Horizontal,
|
||||
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||
Padding = UDim.new(0, 2),
|
||||
[Roact.Change.AbsoluteContentSize] = function(rbx)
|
||||
self.setInputSize(rbx.AbsoluteContentSize)
|
||||
end,
|
||||
}),
|
||||
|
||||
Input = if self.props.input ~= nil
|
||||
then self.props.input
|
||||
elseif self.props.options ~= nil then e(Dropdown, {
|
||||
locked = self.props.locked,
|
||||
options = self.props.options,
|
||||
active = self.state.setting,
|
||||
transparency = self.props.transparency,
|
||||
onClick = function(option)
|
||||
Settings:set(self.props.id, option)
|
||||
end,
|
||||
})
|
||||
else e(Checkbox, {
|
||||
locked = self.props.locked,
|
||||
active = self.state.setting,
|
||||
transparency = self.props.transparency,
|
||||
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,
|
||||
layoutOrder = -1,
|
||||
|
||||
onClick = self.props.onReset,
|
||||
})
|
||||
else nil,
|
||||
}),
|
||||
|
||||
Text = e("Frame", {
|
||||
@@ -76,12 +121,14 @@ function Setting:render()
|
||||
BackgroundTransparency = 1,
|
||||
}, {
|
||||
Name = e("TextLabel", {
|
||||
Text = self.props.name,
|
||||
Text = (if self.props.experimental then '<font color="#FF8E3C">⚠ </font>' else "")
|
||||
.. self.props.name,
|
||||
Font = Enum.Font.GothamBold,
|
||||
TextSize = 17,
|
||||
TextColor3 = theme.Setting.NameColor,
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextTransparency = self.props.transparency,
|
||||
RichText = true,
|
||||
|
||||
Size = UDim2.new(1, 0, 0, 17),
|
||||
|
||||
@@ -90,7 +137,8 @@ function Setting:render()
|
||||
}),
|
||||
|
||||
Description = e("TextLabel", {
|
||||
Text = self.props.description,
|
||||
Text = (if self.props.experimental then '<font color="#FF8E3C">[Experimental] </font>' else "")
|
||||
.. self.props.description,
|
||||
Font = Enum.Font.Gotham,
|
||||
LineHeight = 1.2,
|
||||
TextSize = 14,
|
||||
@@ -98,13 +146,23 @@ function Setting:render()
|
||||
TextXAlignment = Enum.TextXAlignment.Left,
|
||||
TextTransparency = self.props.transparency,
|
||||
TextWrapped = true,
|
||||
RichText = true,
|
||||
|
||||
Size = self.containerSize:map(function(value)
|
||||
Size = Roact.joinBindings({
|
||||
containerSize = self.containerSize,
|
||||
inputSize = self.inputSize,
|
||||
}):map(function(values)
|
||||
local desc = (if self.props.experimental then "[Experimental] " else "")
|
||||
.. self.props.description
|
||||
local offset = values.inputSize.X + 5
|
||||
local textBounds = getTextBounds(
|
||||
self.props.description, 14, Enum.Font.Gotham, 1.2,
|
||||
Vector2.new(value.X - 50, math.huge)
|
||||
desc,
|
||||
14,
|
||||
Enum.Font.Gotham,
|
||||
1.2,
|
||||
Vector2.new(values.containerSize.X - offset, math.huge)
|
||||
)
|
||||
return UDim2.new(1, -50, 0, textBounds.Y)
|
||||
return UDim2.new(1, -offset, 0, textBounds.Y)
|
||||
end),
|
||||
|
||||
LayoutOrder = 2,
|
||||
|
||||
@@ -3,16 +3,31 @@ 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 TextInput = require(Plugin.App.Components.TextInput)
|
||||
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 confirmationBehaviors = { "Initial", "Always", "Large Changes", "Unlisted PlaceId" }
|
||||
|
||||
local function Navbar(props)
|
||||
return Theme.with(function(theme)
|
||||
theme = theme.Settings.Navbar
|
||||
@@ -32,6 +47,10 @@ local function Navbar(props)
|
||||
anchorPoint = Vector2.new(0, 0.5),
|
||||
|
||||
onClick = props.onBack,
|
||||
}, {
|
||||
Tip = e(Tooltip.Trigger, {
|
||||
text = "Back",
|
||||
}),
|
||||
}),
|
||||
|
||||
Text = e("TextLabel", {
|
||||
@@ -44,7 +63,7 @@ local function Navbar(props)
|
||||
Size = UDim2.new(1, 0, 1, 0),
|
||||
|
||||
BackgroundTransparency = 1,
|
||||
})
|
||||
}),
|
||||
})
|
||||
end)
|
||||
end
|
||||
@@ -70,36 +89,114 @@ function SettingsPage:render()
|
||||
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 = 1,
|
||||
}),
|
||||
|
||||
SyncReminder = e(Setting, {
|
||||
id = "syncReminder",
|
||||
name = "Sync Reminder",
|
||||
description = "Notify to sync when opening a place that has previously been synced",
|
||||
transparency = self.props.transparency,
|
||||
visible = Settings:getBinding("showNotifications"),
|
||||
layoutOrder = 2,
|
||||
}),
|
||||
|
||||
ConfirmationBehavior = e(Setting, {
|
||||
id = "confirmationBehavior",
|
||||
name = "Confirmation Behavior",
|
||||
description = "When to prompt for confirmation before syncing",
|
||||
transparency = self.props.transparency,
|
||||
layoutOrder = 3,
|
||||
|
||||
options = confirmationBehaviors,
|
||||
}),
|
||||
|
||||
LargeChangesConfirmationThreshold = e(Setting, {
|
||||
id = "largeChangesConfirmationThreshold",
|
||||
name = "Confirmation Threshold",
|
||||
description = "How many modified instances to be considered a large change",
|
||||
transparency = self.props.transparency,
|
||||
layoutOrder = 4,
|
||||
visible = Settings:getBinding("confirmationBehavior"):map(function(value)
|
||||
return value == "Large Changes"
|
||||
end),
|
||||
input = e(TextInput, {
|
||||
size = UDim2.new(0, 40, 0, 28),
|
||||
text = Settings:getBinding("largeChangesConfirmationThreshold"):map(function(value)
|
||||
return tostring(value)
|
||||
end),
|
||||
transparency = self.props.transparency,
|
||||
enabled = true,
|
||||
onEntered = function(text)
|
||||
local number = tonumber(string.match(text, "%d+"))
|
||||
if number then
|
||||
Settings:set("largeChangesConfirmationThreshold", math.clamp(number, 1, 999))
|
||||
else
|
||||
-- Force text back to last valid value
|
||||
Settings:set(
|
||||
"largeChangesConfirmationThreshold",
|
||||
Settings:get("largeChangesConfirmationThreshold")
|
||||
)
|
||||
end
|
||||
end,
|
||||
}),
|
||||
}),
|
||||
|
||||
PlaySounds = e(Setting, {
|
||||
id = "playSounds",
|
||||
name = "Play Sounds",
|
||||
description = "Toggle sound effects",
|
||||
transparency = self.props.transparency,
|
||||
layoutOrder = 3,
|
||||
layoutOrder = 5,
|
||||
}),
|
||||
|
||||
OpenScriptsExternally = e(Setting, {
|
||||
id = "openScriptsExternally",
|
||||
name = "Open Scripts Externally",
|
||||
description = "Attempt to open scripts in an external editor",
|
||||
locked = self.props.syncActive,
|
||||
experimental = true,
|
||||
transparency = self.props.transparency,
|
||||
layoutOrder = 6,
|
||||
}),
|
||||
|
||||
TwoWaySync = e(Setting, {
|
||||
id = "twoWaySync",
|
||||
name = "Two-Way Sync",
|
||||
description = "EXPERIMENTAL! Editing files in Studio will sync them into the filesystem",
|
||||
description = "Editing files in Studio will sync them into the filesystem",
|
||||
locked = self.props.syncActive,
|
||||
experimental = true,
|
||||
transparency = self.props.transparency,
|
||||
layoutOrder = 4,
|
||||
layoutOrder = 7,
|
||||
}),
|
||||
|
||||
LogLevel = e(Setting, {
|
||||
id = "logLevel",
|
||||
name = "Log Level",
|
||||
description = "Plugin output verbosity level",
|
||||
transparency = self.props.transparency,
|
||||
layoutOrder = 100,
|
||||
|
||||
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 = 101,
|
||||
}),
|
||||
|
||||
Layout = e("UIListLayout", {
|
||||
|
||||
@@ -2,6 +2,7 @@ return {
|
||||
NotConnected = require(script.NotConnected),
|
||||
Settings = require(script.Settings),
|
||||
Connecting = require(script.Connecting),
|
||||
Confirming = require(script.Confirming),
|
||||
Connected = require(script.Connected),
|
||||
Error = require(script.Error),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,171 +23,226 @@ local Log = require(Packages.Log)
|
||||
|
||||
local strict = require(script.Parent.Parent.strict)
|
||||
|
||||
-- Copying hex colors back and forth from design programs is faster
|
||||
local function hexColor(decimal)
|
||||
local red = bit32.band(bit32.rshift(decimal, 16), 2^8 - 1)
|
||||
local green = bit32.band(bit32.rshift(decimal, 8), 2^8 - 1)
|
||||
local blue = bit32.band(decimal, 2^8 - 1)
|
||||
|
||||
return Color3.fromRGB(red, green, blue)
|
||||
end
|
||||
|
||||
local BRAND_COLOR = hexColor(0xE13835)
|
||||
local BRAND_COLOR = Color3.fromHex("E13835")
|
||||
|
||||
local lightTheme = strict("LightTheme", {
|
||||
BackgroundColor = hexColor(0xFFFFFF),
|
||||
BackgroundColor = Color3.fromHex("FFFFFF"),
|
||||
Button = {
|
||||
Solid = {
|
||||
ActionFillColor = hexColor(0xFFFFFF),
|
||||
ActionFillColor = Color3.fromHex("FFFFFF"),
|
||||
ActionFillTransparency = 0.8,
|
||||
Enabled = {
|
||||
TextColor = hexColor(0xFFFFFF),
|
||||
TextColor = Color3.fromHex("FFFFFF"),
|
||||
BackgroundColor = BRAND_COLOR,
|
||||
},
|
||||
Disabled = {
|
||||
TextColor = hexColor(0xFFFFFF),
|
||||
TextColor = Color3.fromHex("FFFFFF"),
|
||||
BackgroundColor = BRAND_COLOR,
|
||||
},
|
||||
},
|
||||
Bordered = {
|
||||
ActionFillColor = hexColor(0x000000),
|
||||
ActionFillColor = Color3.fromHex("000000"),
|
||||
ActionFillTransparency = 0.9,
|
||||
Enabled = {
|
||||
TextColor = hexColor(0x393939),
|
||||
BorderColor = hexColor(0xACACAC),
|
||||
TextColor = Color3.fromHex("393939"),
|
||||
BorderColor = Color3.fromHex("ACACAC"),
|
||||
},
|
||||
Disabled = {
|
||||
TextColor = hexColor(0x393939),
|
||||
BorderColor = hexColor(0xACACAC),
|
||||
TextColor = Color3.fromHex("393939"),
|
||||
BorderColor = Color3.fromHex("ACACAC"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Checkbox = {
|
||||
Active = {
|
||||
IconColor = hexColor(0xFFFFFF),
|
||||
IconColor = Color3.fromHex("FFFFFF"),
|
||||
BackgroundColor = BRAND_COLOR,
|
||||
},
|
||||
Inactive = {
|
||||
IconColor = hexColor(0xEEEEEE),
|
||||
BorderColor = hexColor(0xAFAFAF),
|
||||
IconColor = Color3.fromHex("EEEEEE"),
|
||||
BorderColor = Color3.fromHex("AFAFAF"),
|
||||
},
|
||||
},
|
||||
Dropdown = {
|
||||
TextColor = Color3.fromHex("000000"),
|
||||
BorderColor = Color3.fromHex("AFAFAF"),
|
||||
BackgroundColor = Color3.fromHex("EEEEEE"),
|
||||
Open = {
|
||||
IconColor = BRAND_COLOR,
|
||||
},
|
||||
Closed = {
|
||||
IconColor = Color3.fromHex("EEEEEE"),
|
||||
},
|
||||
},
|
||||
TextInput = {
|
||||
Enabled = {
|
||||
TextColor = Color3.fromHex("000000"),
|
||||
PlaceholderColor = Color3.fromHex("8C8C8C"),
|
||||
BorderColor = Color3.fromHex("ACACAC"),
|
||||
},
|
||||
Disabled = {
|
||||
TextColor = Color3.fromHex("393939"),
|
||||
PlaceholderColor = Color3.fromHex("8C8C8C"),
|
||||
BorderColor = Color3.fromHex("AFAFAF"),
|
||||
},
|
||||
ActionFillColor = Color3.fromHex("000000"),
|
||||
ActionFillTransparency = 0.9,
|
||||
},
|
||||
AddressEntry = {
|
||||
TextColor = hexColor(0x000000),
|
||||
PlaceholderColor = hexColor(0x8C8C8C)
|
||||
TextColor = Color3.fromHex("000000"),
|
||||
PlaceholderColor = Color3.fromHex("8C8C8C"),
|
||||
},
|
||||
BorderedContainer = {
|
||||
BorderColor = hexColor(0xCBCBCB),
|
||||
BackgroundColor = hexColor(0xEEEEEE),
|
||||
BorderColor = Color3.fromHex("CBCBCB"),
|
||||
BackgroundColor = Color3.fromHex("EEEEEE"),
|
||||
},
|
||||
Spinner = {
|
||||
ForegroundColor = BRAND_COLOR,
|
||||
BackgroundColor = hexColor(0xEEEEEE),
|
||||
BackgroundColor = Color3.fromHex("EEEEEE"),
|
||||
},
|
||||
Diff = {
|
||||
Add = Color3.fromHex("baffbd"),
|
||||
Remove = Color3.fromHex("ffbdba"),
|
||||
Edit = Color3.fromHex("bacdff"),
|
||||
Row = Color3.fromHex("000000"),
|
||||
Warning = Color3.fromHex("FF8E3C"),
|
||||
},
|
||||
ConnectionDetails = {
|
||||
ProjectNameColor = hexColor(0x00000),
|
||||
AddressColor = hexColor(0x00000),
|
||||
ProjectNameColor = Color3.fromHex("000000"),
|
||||
AddressColor = Color3.fromHex("000000"),
|
||||
DisconnectColor = BRAND_COLOR,
|
||||
},
|
||||
Settings = {
|
||||
DividerColor = hexColor(0xCBCBCB),
|
||||
DividerColor = Color3.fromHex("CBCBCB"),
|
||||
Navbar = {
|
||||
BackButtonColor = hexColor(0x000000),
|
||||
TextColor = hexColor(0x000000),
|
||||
BackButtonColor = Color3.fromHex("000000"),
|
||||
TextColor = Color3.fromHex("000000"),
|
||||
},
|
||||
Setting = {
|
||||
NameColor = hexColor(0x000000),
|
||||
DescriptionColor = hexColor(0x5F5F5F),
|
||||
NameColor = Color3.fromHex("000000"),
|
||||
DescriptionColor = Color3.fromHex("5F5F5F"),
|
||||
},
|
||||
},
|
||||
Header = {
|
||||
LogoColor = BRAND_COLOR,
|
||||
VersionColor = hexColor(0x727272),
|
||||
VersionColor = Color3.fromHex("727272"),
|
||||
},
|
||||
Notification = {
|
||||
InfoColor = hexColor(0x00000),
|
||||
InfoColor = Color3.fromHex("000000"),
|
||||
CloseColor = BRAND_COLOR,
|
||||
},
|
||||
ErrorColor = hexColor(0x000000),
|
||||
ScrollBarColor = hexColor(0x000000),
|
||||
ErrorColor = Color3.fromHex("000000"),
|
||||
ScrollBarColor = Color3.fromHex("000000"),
|
||||
})
|
||||
|
||||
local darkTheme = strict("DarkTheme", {
|
||||
BackgroundColor = hexColor(0x2E2E2E),
|
||||
BackgroundColor = Color3.fromHex("2E2E2E"),
|
||||
Button = {
|
||||
Solid = {
|
||||
ActionFillColor = hexColor(0xFFFFFF),
|
||||
ActionFillColor = Color3.fromHex("FFFFFF"),
|
||||
ActionFillTransparency = 0.8,
|
||||
Enabled = {
|
||||
TextColor = hexColor(0xFFFFFF),
|
||||
TextColor = Color3.fromHex("FFFFFF"),
|
||||
BackgroundColor = BRAND_COLOR,
|
||||
},
|
||||
Disabled = {
|
||||
TextColor = hexColor(0xFFFFFF),
|
||||
TextColor = Color3.fromHex("FFFFFF"),
|
||||
BackgroundColor = BRAND_COLOR,
|
||||
},
|
||||
},
|
||||
Bordered = {
|
||||
ActionFillColor = hexColor(0xFFFFFF),
|
||||
ActionFillColor = Color3.fromHex("FFFFFF"),
|
||||
ActionFillTransparency = 0.9,
|
||||
Enabled = {
|
||||
TextColor = hexColor(0xDBDBDB),
|
||||
BorderColor = hexColor(0x535353),
|
||||
TextColor = Color3.fromHex("DBDBDB"),
|
||||
BorderColor = Color3.fromHex("535353"),
|
||||
},
|
||||
Disabled = {
|
||||
TextColor = hexColor(0xDBDBDB),
|
||||
BorderColor = hexColor(0x535353),
|
||||
TextColor = Color3.fromHex("DBDBDB"),
|
||||
BorderColor = Color3.fromHex("535353"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Checkbox = {
|
||||
Active = {
|
||||
IconColor = hexColor(0xFFFFFF),
|
||||
IconColor = Color3.fromHex("FFFFFF"),
|
||||
BackgroundColor = BRAND_COLOR,
|
||||
},
|
||||
Inactive = {
|
||||
IconColor = hexColor(0x484848),
|
||||
BorderColor = hexColor(0x5A5A5A),
|
||||
IconColor = Color3.fromHex("484848"),
|
||||
BorderColor = Color3.fromHex("5A5A5A"),
|
||||
},
|
||||
},
|
||||
Dropdown = {
|
||||
TextColor = Color3.fromHex("FFFFFF"),
|
||||
BorderColor = Color3.fromHex("5A5A5A"),
|
||||
BackgroundColor = Color3.fromHex("2B2B2B"),
|
||||
Open = {
|
||||
IconColor = BRAND_COLOR,
|
||||
},
|
||||
Closed = {
|
||||
IconColor = Color3.fromHex("484848"),
|
||||
},
|
||||
},
|
||||
TextInput = {
|
||||
Enabled = {
|
||||
TextColor = Color3.fromHex("FFFFFF"),
|
||||
PlaceholderColor = Color3.fromHex("8B8B8B"),
|
||||
BorderColor = Color3.fromHex("535353"),
|
||||
},
|
||||
Disabled = {
|
||||
TextColor = Color3.fromHex("484848"),
|
||||
PlaceholderColor = Color3.fromHex("8B8B8B"),
|
||||
BorderColor = Color3.fromHex("5A5A5A"),
|
||||
},
|
||||
ActionFillColor = Color3.fromHex("FFFFFF"),
|
||||
ActionFillTransparency = 0.9,
|
||||
},
|
||||
AddressEntry = {
|
||||
TextColor = hexColor(0xFFFFFF),
|
||||
PlaceholderColor = hexColor(0x8B8B8B)
|
||||
TextColor = Color3.fromHex("FFFFFF"),
|
||||
PlaceholderColor = Color3.fromHex("8B8B8B"),
|
||||
},
|
||||
BorderedContainer = {
|
||||
BorderColor = hexColor(0x535353),
|
||||
BackgroundColor = hexColor(0x2B2B2B),
|
||||
BorderColor = Color3.fromHex("535353"),
|
||||
BackgroundColor = Color3.fromHex("2B2B2B"),
|
||||
},
|
||||
Spinner = {
|
||||
ForegroundColor = BRAND_COLOR,
|
||||
BackgroundColor = hexColor(0x2B2B2B),
|
||||
BackgroundColor = Color3.fromHex("2B2B2B"),
|
||||
},
|
||||
Diff = {
|
||||
Add = Color3.fromHex("273732"),
|
||||
Remove = Color3.fromHex("3F2D32"),
|
||||
Edit = Color3.fromHex("193345"),
|
||||
Row = Color3.fromHex("FFFFFF"),
|
||||
Warning = Color3.fromHex("FF8E3C"),
|
||||
},
|
||||
ConnectionDetails = {
|
||||
ProjectNameColor = hexColor(0xFFFFFF),
|
||||
AddressColor = hexColor(0xFFFFFF),
|
||||
DisconnectColor = hexColor(0xFFFFFF),
|
||||
ProjectNameColor = Color3.fromHex("FFFFFF"),
|
||||
AddressColor = Color3.fromHex("FFFFFF"),
|
||||
DisconnectColor = Color3.fromHex("FFFFFF"),
|
||||
},
|
||||
Settings = {
|
||||
DividerColor = hexColor(0x535353),
|
||||
DividerColor = Color3.fromHex("535353"),
|
||||
Navbar = {
|
||||
BackButtonColor = hexColor(0xFFFFFF),
|
||||
TextColor = hexColor(0xFFFFFF),
|
||||
BackButtonColor = Color3.fromHex("FFFFFF"),
|
||||
TextColor = Color3.fromHex("FFFFFF"),
|
||||
},
|
||||
Setting = {
|
||||
NameColor = hexColor(0xFFFFFF),
|
||||
DescriptionColor = hexColor(0xD3D3D3),
|
||||
NameColor = Color3.fromHex("FFFFFF"),
|
||||
DescriptionColor = Color3.fromHex("D3D3D3"),
|
||||
},
|
||||
},
|
||||
Header = {
|
||||
LogoColor = BRAND_COLOR,
|
||||
VersionColor = hexColor(0xD3D3D3)
|
||||
VersionColor = Color3.fromHex("D3D3D3"),
|
||||
},
|
||||
Notification = {
|
||||
InfoColor = hexColor(0xFFFFFF),
|
||||
CloseColor = hexColor(0xFFFFFF),
|
||||
InfoColor = Color3.fromHex("FFFFFF"),
|
||||
CloseColor = Color3.fromHex("FFFFFF"),
|
||||
},
|
||||
ErrorColor = hexColor(0xFFFFFF),
|
||||
ScrollBarColor = hexColor(0xFFFFFF),
|
||||
ErrorColor = Color3.fromHex("FFFFFF"),
|
||||
ScrollBarColor = Color3.fromHex("FFFFFF"),
|
||||
})
|
||||
|
||||
local Context = Roact.createContext(lightTheme)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
local ChangeHistoryService = game:GetService("ChangeHistoryService")
|
||||
local Players = game:GetService("Players")
|
||||
local ServerStorage = game:GetService("ServerStorage")
|
||||
local RunService = game:GetService("RunService")
|
||||
|
||||
local Rojo = script:FindFirstAncestor("Rojo")
|
||||
local Plugin = Rojo.Plugin
|
||||
@@ -16,12 +18,16 @@ local strict = require(Plugin.strict)
|
||||
local Dictionary = require(Plugin.Dictionary)
|
||||
local ServeSession = require(Plugin.ServeSession)
|
||||
local ApiContext = require(Plugin.ApiContext)
|
||||
local PatchSet = require(Plugin.PatchSet)
|
||||
local PatchTree = require(Plugin.PatchTree)
|
||||
local preloadAssets = require(Plugin.preloadAssets)
|
||||
local soundPlayer = require(Plugin.soundPlayer)
|
||||
local ignorePlaceIds = require(Plugin.ignorePlaceIds)
|
||||
local Theme = require(script.Theme)
|
||||
|
||||
local Page = require(script.Page)
|
||||
local Notifications = require(script.Notifications)
|
||||
local Tooltip = require(script.Components.Tooltip)
|
||||
local StudioPluginAction = require(script.Components.Studio.StudioPluginAction)
|
||||
local StudioToolbar = require(script.Components.Studio.StudioToolbar)
|
||||
local StudioToggleButton = require(script.Components.Studio.StudioToggleButton)
|
||||
@@ -33,6 +39,7 @@ local AppStatus = strict("AppStatus", {
|
||||
NotConnected = "NotConnected",
|
||||
Settings = "Settings",
|
||||
Connecting = "Connecting",
|
||||
Confirming = "Confirming",
|
||||
Connected = "Connected",
|
||||
Error = "Error",
|
||||
})
|
||||
@@ -44,45 +51,226 @@ local App = Roact.Component:extend("App")
|
||||
function App:init()
|
||||
preloadAssets()
|
||||
|
||||
self.host, self.setHost = Roact.createBinding("")
|
||||
self.port, self.setPort = Roact.createBinding("")
|
||||
self.patchInfo, self.setPatchInfo = Roact.createBinding({
|
||||
changes = 0,
|
||||
timestamp = os.time(),
|
||||
})
|
||||
local priorHost, priorPort = self:getPriorEndpoint()
|
||||
self.host, self.setHost = Roact.createBinding(priorHost or "")
|
||||
self.port, self.setPort = Roact.createBinding(priorPort or "")
|
||||
|
||||
self.confirmationBindable = Instance.new("BindableEvent")
|
||||
self.confirmationEvent = self.confirmationBindable.Event
|
||||
self.knownProjects = {}
|
||||
self.notifId = 0
|
||||
|
||||
self.waypointConnection = ChangeHistoryService.OnUndo:Connect(function(action: string)
|
||||
if not string.find(action, "^Rojo: Patch") then
|
||||
return
|
||||
end
|
||||
|
||||
local undoConnection, redoConnection = nil, nil
|
||||
local function cleanup()
|
||||
undoConnection:Disconnect()
|
||||
redoConnection:Disconnect()
|
||||
end
|
||||
|
||||
Log.warn(
|
||||
string.format(
|
||||
"You've undone '%s'.\nIf this was not intended, please Redo in the topbar or with Ctrl/⌘+Y.",
|
||||
action
|
||||
)
|
||||
)
|
||||
local dismissNotif = self:addNotification(
|
||||
string.format("You've undone '%s'.\nIf this was not intended, please restore.", action),
|
||||
10,
|
||||
{
|
||||
Restore = {
|
||||
text = "Restore",
|
||||
style = "Solid",
|
||||
layoutOrder = 1,
|
||||
onClick = function(notification)
|
||||
cleanup()
|
||||
notification:dismiss()
|
||||
ChangeHistoryService:Redo()
|
||||
end,
|
||||
},
|
||||
Dismiss = {
|
||||
text = "Dismiss",
|
||||
style = "Bordered",
|
||||
layoutOrder = 2,
|
||||
onClick = function(notification)
|
||||
cleanup()
|
||||
notification:dismiss()
|
||||
end,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
undoConnection = ChangeHistoryService.OnUndo:Once(function()
|
||||
-- Our notif is now out of date- redoing will not restore the patch
|
||||
-- since we've undone even further. Dismiss the notif.
|
||||
cleanup()
|
||||
dismissNotif()
|
||||
end)
|
||||
redoConnection = ChangeHistoryService.OnRedo:Once(function(redoneAction: string)
|
||||
if redoneAction == action then
|
||||
-- The user has restored the patch, so we can dismiss the notif
|
||||
cleanup()
|
||||
dismissNotif()
|
||||
end
|
||||
end)
|
||||
end)
|
||||
|
||||
self:setState({
|
||||
appStatus = AppStatus.NotConnected,
|
||||
guiEnabled = false,
|
||||
confirmData = {},
|
||||
patchData = {
|
||||
patch = PatchSet.newEmpty(),
|
||||
unapplied = PatchSet.newEmpty(),
|
||||
timestamp = os.time(),
|
||||
},
|
||||
notifications = {},
|
||||
toolbarIcon = Assets.Images.PluginButton,
|
||||
})
|
||||
|
||||
if
|
||||
RunService:IsEdit()
|
||||
and self.serveSession == nil
|
||||
and Settings:get("syncReminder")
|
||||
and self:getLastSyncTimestamp()
|
||||
then
|
||||
self:addNotification("You've previously synced this place. Would you like to reconnect?", 300, {
|
||||
Connect = {
|
||||
text = "Connect",
|
||||
style = "Solid",
|
||||
layoutOrder = 1,
|
||||
onClick = function(notification)
|
||||
notification:dismiss()
|
||||
self:startSession()
|
||||
end,
|
||||
},
|
||||
Dismiss = {
|
||||
text = "Dismiss",
|
||||
style = "Bordered",
|
||||
layoutOrder = 2,
|
||||
onClick = function(notification)
|
||||
notification:dismiss()
|
||||
end,
|
||||
},
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
function App:addNotification(text: string, timeout: number?)
|
||||
function App:willUnmount()
|
||||
self.waypointConnection:Disconnect()
|
||||
self.confirmationBindable:Destroy()
|
||||
end
|
||||
|
||||
function App:addNotification(
|
||||
text: string,
|
||||
timeout: number?,
|
||||
actions: { [string]: { text: string, style: string, layoutOrder: number, onClick: (any) -> () } }?
|
||||
)
|
||||
if not Settings:get("showNotifications") then
|
||||
return
|
||||
end
|
||||
|
||||
self.notifId += 1
|
||||
local id = self.notifId
|
||||
|
||||
local notifications = table.clone(self.state.notifications)
|
||||
table.insert(notifications, {
|
||||
notifications[id] = {
|
||||
text = text,
|
||||
timestamp = DateTime.now().UnixTimestampMillis,
|
||||
timeout = timeout or 3,
|
||||
actions = actions,
|
||||
}
|
||||
|
||||
self:setState({
|
||||
notifications = notifications,
|
||||
})
|
||||
|
||||
return function()
|
||||
self:closeNotification(id)
|
||||
end
|
||||
end
|
||||
|
||||
function App:closeNotification(id: number)
|
||||
if not self.state.notifications[id] then
|
||||
return
|
||||
end
|
||||
|
||||
local notifications = table.clone(self.state.notifications)
|
||||
notifications[id] = nil
|
||||
|
||||
self:setState({
|
||||
notifications = notifications,
|
||||
})
|
||||
end
|
||||
|
||||
function App:closeNotification(index: number)
|
||||
local notifications = table.clone(self.state.notifications)
|
||||
table.remove(notifications, index)
|
||||
function App:getPriorEndpoint()
|
||||
local priorEndpoints = Settings:get("priorEndpoints")
|
||||
if not priorEndpoints then
|
||||
return
|
||||
end
|
||||
|
||||
self:setState({
|
||||
notifications = notifications,
|
||||
})
|
||||
local id = tostring(game.PlaceId)
|
||||
if ignorePlaceIds[id] then
|
||||
return
|
||||
end
|
||||
|
||||
local place = priorEndpoints[id]
|
||||
if not place then
|
||||
return
|
||||
end
|
||||
|
||||
return place.host, place.port
|
||||
end
|
||||
|
||||
function App:getLastSyncTimestamp()
|
||||
local priorEndpoints = Settings:get("priorEndpoints")
|
||||
if not priorEndpoints then
|
||||
return
|
||||
end
|
||||
|
||||
local id = tostring(game.PlaceId)
|
||||
if ignorePlaceIds[id] then
|
||||
return
|
||||
end
|
||||
|
||||
local place = priorEndpoints[id]
|
||||
if not place then
|
||||
return
|
||||
end
|
||||
|
||||
return place.timestamp
|
||||
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
|
||||
|
||||
local id = tostring(game.PlaceId)
|
||||
if ignorePlaceIds[id] then
|
||||
return
|
||||
end
|
||||
|
||||
priorEndpoints[id] = {
|
||||
host = if host ~= Config.defaultHost then host else nil,
|
||||
port = if port ~= Config.defaultPort then port else nil,
|
||||
timestamp = os.time(),
|
||||
}
|
||||
Log.trace("Saved last used endpoint for {}", game.PlaceId)
|
||||
|
||||
Settings:set("priorEndpoints", priorEndpoints)
|
||||
end
|
||||
|
||||
function App:getHostAndPort()
|
||||
@@ -161,7 +349,9 @@ function App:startSession()
|
||||
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 serveSession = ServeSession.new({
|
||||
@@ -170,42 +360,65 @@ function App:startSession()
|
||||
twoWaySync = sessionOptions.twoWaySync,
|
||||
})
|
||||
|
||||
serveSession:onPatchApplied(function(patch, unapplied)
|
||||
self.cleanupPrecommit = serveSession.__reconciler:hookPrecommit(function(patch, instanceMap)
|
||||
-- Build new tree for patch
|
||||
self:setState({
|
||||
patchTree = PatchTree.build(patch, instanceMap, { "Property", "Old", "New" }),
|
||||
})
|
||||
end)
|
||||
self.cleanupPostcommit = serveSession.__reconciler:hookPostcommit(function(patch, instanceMap, unappliedPatch)
|
||||
-- Update tree with unapplied metadata
|
||||
self:setState(function(prevState)
|
||||
return {
|
||||
patchTree = PatchTree.updateMetadata(prevState.patchTree, patch, instanceMap, unappliedPatch),
|
||||
}
|
||||
end)
|
||||
end)
|
||||
|
||||
serveSession:hookPostcommit(function(patch, _instanceMap, unapplied)
|
||||
local now = os.time()
|
||||
local changes = 0
|
||||
local old = self.state.patchData
|
||||
|
||||
for _, set in patch do
|
||||
for _ in set do
|
||||
changes += 1
|
||||
end
|
||||
end
|
||||
for _, set in unapplied do
|
||||
for _ in set do
|
||||
changes -= 1
|
||||
end
|
||||
if PatchSet.isEmpty(patch) then
|
||||
-- Ignore empty patch, but update timestamp
|
||||
self:setState({
|
||||
patchData = {
|
||||
patch = old.patch,
|
||||
unapplied = old.unapplied,
|
||||
timestamp = now,
|
||||
},
|
||||
})
|
||||
return
|
||||
end
|
||||
|
||||
if changes == 0 then return end
|
||||
|
||||
local old = self.patchInfo:getValue()
|
||||
if now - old.timestamp < 2 then
|
||||
changes += old.changes
|
||||
-- Patches that apply in the same second are
|
||||
-- considered to be part of the same change for human clarity
|
||||
patch = PatchSet.assign(PatchSet.newEmpty(), old.patch, patch)
|
||||
unapplied = PatchSet.assign(PatchSet.newEmpty(), old.unapplied, unapplied)
|
||||
end
|
||||
|
||||
self.setPatchInfo({
|
||||
changes = changes,
|
||||
timestamp = now,
|
||||
self:setState({
|
||||
patchData = {
|
||||
patch = patch,
|
||||
unapplied = unapplied,
|
||||
timestamp = now,
|
||||
},
|
||||
})
|
||||
end)
|
||||
|
||||
serveSession:onStatusChanged(function(status, details)
|
||||
if status == ServeSession.Status.Connecting then
|
||||
self:setPriorEndpoint(host, port)
|
||||
|
||||
self:setState({
|
||||
appStatus = AppStatus.Connecting,
|
||||
toolbarIcon = Assets.Images.PluginButton,
|
||||
})
|
||||
self:addNotification("Connecting to session...")
|
||||
elseif status == ServeSession.Status.Connected then
|
||||
self.knownProjects[details] = true
|
||||
|
||||
local address = ("%s:%s"):format(host, port)
|
||||
self:setState({
|
||||
appStatus = AppStatus.Connected,
|
||||
@@ -217,6 +430,13 @@ function App:startSession()
|
||||
elseif status == ServeSession.Status.Disconnected then
|
||||
self.serveSession = nil
|
||||
self:releaseSyncLock()
|
||||
self:setState({
|
||||
patchData = {
|
||||
patch = PatchSet.newEmpty(),
|
||||
unapplied = PatchSet.newEmpty(),
|
||||
timestamp = os.time(),
|
||||
},
|
||||
})
|
||||
|
||||
-- Details being present indicates that this
|
||||
-- disconnection was from an error.
|
||||
@@ -239,19 +459,84 @@ function App:startSession()
|
||||
end
|
||||
end)
|
||||
|
||||
serveSession:setConfirmCallback(function(instanceMap, patch, serverInfo)
|
||||
if PatchSet.isEmpty(patch) then
|
||||
Log.trace("Accepting patch without confirmation because it is empty")
|
||||
return "Accept"
|
||||
end
|
||||
|
||||
local confirmationBehavior = Settings:get("confirmationBehavior")
|
||||
if confirmationBehavior == "Initial" then
|
||||
-- Only confirm if we haven't synced this project yet this session
|
||||
if self.knownProjects[serverInfo.projectName] then
|
||||
Log.trace(
|
||||
"Accepting patch without confirmation because project has already been connected and behavior is set to Initial"
|
||||
)
|
||||
return "Accept"
|
||||
end
|
||||
elseif confirmationBehavior == "Large Changes" then
|
||||
-- Only confirm if the patch impacts many instances
|
||||
if PatchSet.countInstances(patch) < Settings:get("largeChangesConfirmationThreshold") then
|
||||
Log.trace(
|
||||
"Accepting patch without confirmation because patch is small and behavior is set to Large Changes"
|
||||
)
|
||||
return "Accept"
|
||||
end
|
||||
elseif confirmationBehavior == "Unlisted PlaceId" then
|
||||
-- Only confirm if the current placeId is not in the servePlaceIds allowlist
|
||||
if serverInfo.expectedPlaceIds then
|
||||
local isListed = table.find(serverInfo.expectedPlaceIds, game.PlaceId) ~= nil
|
||||
if isListed then
|
||||
Log.trace(
|
||||
"Accepting patch without confirmation because placeId is listed and behavior is set to Unlisted PlaceId"
|
||||
)
|
||||
return "Accept"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- The datamodel name gets overwritten by Studio, making confirmation of it intrusive
|
||||
-- and unnecessary. This special case allows it to be accepted without confirmation.
|
||||
if
|
||||
PatchSet.hasAdditions(patch) == false
|
||||
and PatchSet.hasRemoves(patch) == false
|
||||
and PatchSet.containsOnlyInstance(patch, instanceMap, game)
|
||||
then
|
||||
local datamodelUpdates = PatchSet.getUpdateForInstance(patch, instanceMap, game)
|
||||
if
|
||||
datamodelUpdates ~= nil
|
||||
and next(datamodelUpdates.changedProperties) == nil
|
||||
and datamodelUpdates.changedClassName == nil
|
||||
then
|
||||
Log.trace("Accepting patch without confirmation because it only contains a datamodel name change")
|
||||
return "Accept"
|
||||
end
|
||||
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()
|
||||
|
||||
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
|
||||
|
||||
function App:endSession()
|
||||
@@ -267,6 +552,13 @@ function App:endSession()
|
||||
appStatus = AppStatus.NotConnected,
|
||||
})
|
||||
|
||||
if self.cleanupPrecommit ~= nil then
|
||||
self.cleanupPrecommit()
|
||||
end
|
||||
if self.cleanupPostcommit ~= nil then
|
||||
self.cleanupPostcommit()
|
||||
end
|
||||
|
||||
Log.trace("Session terminated by user")
|
||||
end
|
||||
|
||||
@@ -288,108 +580,135 @@ function App:render()
|
||||
value = self.props.plugin,
|
||||
}, {
|
||||
e(Theme.StudioProvider, nil, {
|
||||
gui = e(StudioPluginGui, {
|
||||
id = pluginName,
|
||||
title = pluginName,
|
||||
active = self.state.guiEnabled,
|
||||
e(Tooltip.Provider, nil, {
|
||||
gui = e(StudioPluginGui, {
|
||||
id = pluginName,
|
||||
title = pluginName,
|
||||
active = self.state.guiEnabled,
|
||||
isEphemeral = false,
|
||||
|
||||
initDockState = Enum.InitialDockState.Right,
|
||||
initEnabled = false,
|
||||
overridePreviousState = false,
|
||||
floatingSize = Vector2.new(300, 200),
|
||||
minimumSize = Vector2.new(300, 200),
|
||||
initDockState = Enum.InitialDockState.Right,
|
||||
overridePreviousState = false,
|
||||
floatingSize = Vector2.new(320, 210),
|
||||
minimumSize = Vector2.new(300, 210),
|
||||
|
||||
zIndexBehavior = Enum.ZIndexBehavior.Sibling,
|
||||
zIndexBehavior = Enum.ZIndexBehavior.Sibling,
|
||||
|
||||
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()
|
||||
onInitialState = function(initialState)
|
||||
self:setState({
|
||||
appStatus = AppStatus.Settings,
|
||||
guiEnabled = initialState,
|
||||
})
|
||||
end,
|
||||
}),
|
||||
|
||||
Connecting = createPageElement(AppStatus.Connecting),
|
||||
|
||||
Connected = createPageElement(AppStatus.Connected, {
|
||||
projectName = self.state.projectName,
|
||||
address = self.state.address,
|
||||
patchInfo = self.patchInfo,
|
||||
|
||||
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,
|
||||
guiEnabled = false,
|
||||
})
|
||||
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.backPage = AppStatus.NotConnected
|
||||
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,
|
||||
patchTree = self.state.patchTree,
|
||||
patchData = self.state.patchData,
|
||||
serveSession = self.serveSession,
|
||||
|
||||
onDisconnect = function()
|
||||
self:endSession()
|
||||
end,
|
||||
|
||||
onNavigateSettings = function()
|
||||
self.backPage = AppStatus.Connected
|
||||
self:setState({
|
||||
appStatus = AppStatus.Settings,
|
||||
})
|
||||
end,
|
||||
}),
|
||||
|
||||
Settings = createPageElement(AppStatus.Settings, {
|
||||
syncActive = self.serveSession ~= nil
|
||||
and self.serveSession:getStatus() == ServeSession.Status.Connected,
|
||||
|
||||
onBack = function()
|
||||
self:setState({
|
||||
appStatus = self.backPage or 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)
|
||||
return e("Frame", {
|
||||
Size = UDim2.new(1, 0, 1, 0),
|
||||
BackgroundColor3 = theme.BackgroundColor,
|
||||
ZIndex = 0,
|
||||
BorderSizePixel = 0,
|
||||
})
|
||||
end),
|
||||
}),
|
||||
|
||||
RojoNotifications = e("ScreenGui", {}, {
|
||||
layout = e("UIListLayout", {
|
||||
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||
HorizontalAlignment = Enum.HorizontalAlignment.Right,
|
||||
VerticalAlignment = Enum.VerticalAlignment.Bottom,
|
||||
Padding = UDim.new(0, 5),
|
||||
}),
|
||||
padding = e("UIPadding", {
|
||||
PaddingTop = UDim.new(0, 5);
|
||||
PaddingBottom = UDim.new(0, 5);
|
||||
PaddingLeft = UDim.new(0, 5);
|
||||
PaddingRight = UDim.new(0, 5);
|
||||
}),
|
||||
notifs = e(Notifications, {
|
||||
soundPlayer = self.props.soundPlayer,
|
||||
notifications = self.state.notifications,
|
||||
onClose = function(index)
|
||||
self:closeNotification(index)
|
||||
end,
|
||||
RojoNotifications = e("ScreenGui", {
|
||||
ZIndexBehavior = Enum.ZIndexBehavior.Sibling,
|
||||
ResetOnSpawn = false,
|
||||
DisplayOrder = 100,
|
||||
}, {
|
||||
layout = e("UIListLayout", {
|
||||
SortOrder = Enum.SortOrder.LayoutOrder,
|
||||
HorizontalAlignment = Enum.HorizontalAlignment.Right,
|
||||
VerticalAlignment = Enum.VerticalAlignment.Bottom,
|
||||
Padding = UDim.new(0, 5),
|
||||
}),
|
||||
padding = e("UIPadding", {
|
||||
PaddingTop = UDim.new(0, 5),
|
||||
PaddingBottom = UDim.new(0, 5),
|
||||
PaddingLeft = UDim.new(0, 5),
|
||||
PaddingRight = UDim.new(0, 5),
|
||||
}),
|
||||
notifs = e(Notifications, {
|
||||
soundPlayer = self.props.soundPlayer,
|
||||
notifications = self.state.notifications,
|
||||
onClose = function(id)
|
||||
self:closeNotification(id)
|
||||
end,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -402,7 +721,9 @@ function App:render()
|
||||
onTriggered = function()
|
||||
if self.serveSession == nil or self.serveSession:getStatus() == ServeSession.Status.NotStarted then
|
||||
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()
|
||||
end
|
||||
end,
|
||||
@@ -450,7 +771,7 @@ function App:render()
|
||||
}
|
||||
end)
|
||||
end,
|
||||
})
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -23,10 +23,22 @@ local Assets = {
|
||||
Icons = {
|
||||
Close = "rbxassetid://6012985953",
|
||||
Back = "rbxassetid://6017213752",
|
||||
Reset = "rbxassetid://10142422327",
|
||||
Expand = "rbxassetid://12045401097",
|
||||
},
|
||||
Diff = {
|
||||
Add = "rbxassetid://10434145835",
|
||||
Remove = "rbxassetid://10434408368",
|
||||
Edit = "rbxassetid://10434144680",
|
||||
},
|
||||
Checkbox = {
|
||||
Active = "rbxassetid://6016251644",
|
||||
Inactive = "rbxassetid://6016251963",
|
||||
Locked = "rbxassetid://14011257320",
|
||||
},
|
||||
Dropdown = {
|
||||
Arrow = "rbxassetid://10131770538",
|
||||
Locked = "rbxassetid://14011257320",
|
||||
},
|
||||
Spinner = {
|
||||
Foreground = "rbxassetid://3222731032",
|
||||
@@ -42,7 +54,7 @@ local Assets = {
|
||||
[32] = "rbxassetid://3088713341",
|
||||
[64] = "rbxassetid://4918677124",
|
||||
[128] = "rbxassetid://2600845734",
|
||||
[500] = "rbxassetid://2609138523"
|
||||
[500] = "rbxassetid://2609138523",
|
||||
},
|
||||
},
|
||||
Sounds = {
|
||||
|
||||
@@ -2,12 +2,25 @@ local strict = require(script.Parent.strict)
|
||||
|
||||
local isDevBuild = script.Parent.Parent:FindFirstChild("ROJO_DEV_BUILD") ~= nil
|
||||
|
||||
local Version = script.Parent.Parent.Version
|
||||
local major, minor, patch, metadata = Version.Value:match("^(%d+)%.(%d+)%.(%d+)(.*)$")
|
||||
|
||||
local realVersion = { major, minor, patch, metadata }
|
||||
for i = 1, 3 do
|
||||
local num = tonumber(realVersion[i])
|
||||
if num then
|
||||
realVersion[i] = num
|
||||
else
|
||||
error(("invalid version `%s` (field %d)"):format(realVersion[i], i))
|
||||
end
|
||||
end
|
||||
|
||||
return strict("Config", {
|
||||
isDevBuild = isDevBuild,
|
||||
codename = "Epiphany",
|
||||
version = {7, 2, 1},
|
||||
expectedServerVersionString = "7.2 or newer",
|
||||
version = realVersion,
|
||||
expectedServerVersionString = ("%d.%d or newer"):format(realVersion[1], realVersion[2]),
|
||||
protocolVersion = 4,
|
||||
defaultHost = "localhost",
|
||||
defaultPort = 34872,
|
||||
})
|
||||
defaultPort = "34872",
|
||||
})
|
||||
|
||||
@@ -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
|
||||
@@ -30,4 +30,4 @@ end
|
||||
return {
|
||||
None = None,
|
||||
merge = merge,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ function InstanceMap:__fmtDebug(output)
|
||||
for id, instance in pairs(self.fromIds) do
|
||||
local label = string.format("%s (%s)", instance:GetFullName(), instance.ClassName)
|
||||
|
||||
table.insert(entries, {id, label})
|
||||
table.insert(entries, { id, label })
|
||||
end
|
||||
|
||||
table.sort(entries, function(a, b)
|
||||
|
||||
@@ -8,6 +8,42 @@ local t = require(Packages.t)
|
||||
|
||||
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 = {}
|
||||
|
||||
PatchSet.validate = t.interface({
|
||||
@@ -31,9 +67,7 @@ end
|
||||
Tells whether the given PatchSet is empty.
|
||||
]]
|
||||
function PatchSet.isEmpty(patchSet)
|
||||
return next(patchSet.removed) == nil and
|
||||
next(patchSet.added) == nil and
|
||||
next(patchSet.updated) == nil
|
||||
return next(patchSet.removed) == nil and next(patchSet.added) == nil and next(patchSet.updated) == nil
|
||||
end
|
||||
|
||||
--[[
|
||||
@@ -57,6 +91,172 @@ function PatchSet.hasUpdates(patchSet)
|
||||
return next(patchSet.updated) ~= nil
|
||||
end
|
||||
|
||||
--[[
|
||||
Tells whether the given PatchSet contains changes to the given instance id
|
||||
]]
|
||||
function PatchSet.containsId(patchSet, instanceMap, id)
|
||||
if patchSet.added[id] ~= nil then
|
||||
return true
|
||||
end
|
||||
|
||||
for _, idOrInstance in patchSet.removed do
|
||||
local removedId = if Types.RbxId(idOrInstance) then idOrInstance else instanceMap.fromInstances[idOrInstance]
|
||||
if removedId == id then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
for _, update in patchSet.updated do
|
||||
if update.id == id then
|
||||
return true
|
||||
end
|
||||
end
|
||||
|
||||
return false
|
||||
end
|
||||
|
||||
--[[
|
||||
Tells whether the given PatchSet contains changes to the given instance.
|
||||
If the given InstanceMap does not contain the instance, this function always returns false.
|
||||
]]
|
||||
function PatchSet.containsInstance(patchSet, instanceMap, instance)
|
||||
local id = instanceMap.fromInstances[instance]
|
||||
if id == nil then
|
||||
return false
|
||||
end
|
||||
|
||||
return PatchSet.containsId(patchSet, instanceMap, id)
|
||||
end
|
||||
|
||||
--[[
|
||||
Tells whether the given PatchSet contains changes to nothing but the given instance id
|
||||
]]
|
||||
function PatchSet.containsOnlyId(patchSet, instanceMap, id)
|
||||
if not PatchSet.containsId(patchSet, instanceMap, id) then
|
||||
-- Patch doesn't contain the id at all
|
||||
return false
|
||||
end
|
||||
|
||||
for addedId in patchSet.added do
|
||||
if addedId ~= id then
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
for _, idOrInstance in patchSet.removed do
|
||||
local removedId = if Types.RbxId(idOrInstance) then idOrInstance else instanceMap.fromInstances[idOrInstance]
|
||||
if removedId ~= id then
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
for _, update in patchSet.updated do
|
||||
if update.id ~= id then
|
||||
return false
|
||||
end
|
||||
end
|
||||
|
||||
return true
|
||||
end
|
||||
|
||||
--[[
|
||||
Tells whether the given PatchSet contains changes to nothing but the given instance.
|
||||
If the given InstanceMap does not contain the instance, this function always returns false.
|
||||
]]
|
||||
function PatchSet.containsOnlyInstance(patchSet, instanceMap, instance)
|
||||
local id = instanceMap.fromInstances[instance]
|
||||
if id == nil then
|
||||
return false
|
||||
end
|
||||
|
||||
return PatchSet.containsOnlyId(patchSet, instanceMap, id)
|
||||
end
|
||||
|
||||
--[[
|
||||
Returns the update to the given instance id, or nil if there aren't any
|
||||
]]
|
||||
function PatchSet.getUpdateForId(patchSet, id)
|
||||
for _, update in patchSet.updated do
|
||||
if update.id == id then
|
||||
return update
|
||||
end
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
--[[
|
||||
Returns the update to the given instance, or nil if there aren't any.
|
||||
If the given InstanceMap does not contain the instance, this function always returns nil.
|
||||
]]
|
||||
function PatchSet.getUpdateForInstance(patchSet, instanceMap, instance)
|
||||
local id = instanceMap.fromInstances[instance]
|
||||
if id == nil then
|
||||
return nil
|
||||
end
|
||||
|
||||
return PatchSet.getUpdateForId(patchSet, id)
|
||||
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
|
||||
-- Adding an instance is 1 change
|
||||
count += 1
|
||||
end
|
||||
for _ in patch.removed do
|
||||
-- Removing an instance is 1 change
|
||||
count += 1
|
||||
end
|
||||
for _, update in patch.updated do
|
||||
-- Updating an instance is 1 change per property updated
|
||||
for _ in update.changedProperties do
|
||||
count += 1
|
||||
end
|
||||
if update.changedName ~= nil then
|
||||
count += 1
|
||||
end
|
||||
if update.changedClassName ~= nil then
|
||||
count += 1
|
||||
end
|
||||
end
|
||||
|
||||
return count
|
||||
end
|
||||
|
||||
--[[
|
||||
Count the number of instances affected by the given PatchSet.
|
||||
]]
|
||||
function PatchSet.countInstances(patch)
|
||||
local count = 0
|
||||
|
||||
-- Added instances
|
||||
for _ in patch.added do
|
||||
count += 1
|
||||
end
|
||||
-- Removed instances
|
||||
for _ in patch.removed do
|
||||
count += 1
|
||||
end
|
||||
-- Updated instances
|
||||
for _ in patch.updated do
|
||||
count += 1
|
||||
end
|
||||
|
||||
return count
|
||||
end
|
||||
|
||||
--[[
|
||||
Merge multiple PatchSet objects into the given PatchSet.
|
||||
]]
|
||||
@@ -90,7 +290,7 @@ function PatchSet.humanSummary(instanceMap, patchSet)
|
||||
for _, idOrInstance in ipairs(patchSet.removed) do
|
||||
local instance, id
|
||||
|
||||
if type(idOrInstance) == "string" then
|
||||
if Types.RbxId(idOrInstance) then
|
||||
id = idOrInstance
|
||||
instance = instanceMap.fromIds[id]
|
||||
else
|
||||
@@ -142,9 +342,15 @@ function PatchSet.humanSummary(instanceMap, patchSet)
|
||||
end
|
||||
end
|
||||
|
||||
table.insert(statements, string.format(
|
||||
"- Add instance %q (ClassName %q) to %s",
|
||||
virtualInstance.Name, virtualInstance.ClassName, parentDisplayName))
|
||||
table.insert(
|
||||
statements,
|
||||
string.format(
|
||||
"- Add instance %q (ClassName %q) to %s",
|
||||
virtualInstance.Name,
|
||||
virtualInstance.ClassName,
|
||||
parentDisplayName
|
||||
)
|
||||
)
|
||||
end
|
||||
|
||||
for _, update in ipairs(patchSet.updated) do
|
||||
@@ -174,9 +380,10 @@ function PatchSet.humanSummary(instanceMap, patchSet)
|
||||
displayName = "[unknown instance]"
|
||||
end
|
||||
|
||||
table.insert(statements, string.format(
|
||||
"- Update properties on %s: %s",
|
||||
displayName, table.concat(updatedProperties, ",")))
|
||||
table.insert(
|
||||
statements,
|
||||
string.format("- Update properties on %s: %s", displayName, table.concat(updatedProperties, ","))
|
||||
)
|
||||
end
|
||||
|
||||
return table.concat(statements, "\n")
|
||||
|
||||
467
plugin/src/PatchTree.lua
Normal file
467
plugin/src/PatchTree.lua
Normal file
@@ -0,0 +1,467 @@
|
||||
--[[
|
||||
Methods to turn PatchSets into trees matching the DataModel containing
|
||||
the changes and metadata for use in the PatchVisualizer component.
|
||||
]]
|
||||
|
||||
local HttpService = game:GetService("HttpService")
|
||||
|
||||
local Rojo = script:FindFirstAncestor("Rojo")
|
||||
local Plugin = Rojo.Plugin
|
||||
local Packages = Rojo.Packages
|
||||
|
||||
local Log = require(Packages.Log)
|
||||
|
||||
local Types = require(Plugin.Types)
|
||||
local decodeValue = require(Plugin.Reconciler.decodeValue)
|
||||
local getProperty = require(Plugin.Reconciler.getProperty)
|
||||
|
||||
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 Tree = {}
|
||||
Tree.__index = Tree
|
||||
|
||||
function Tree.new()
|
||||
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
|
||||
|
||||
return setmetatable(tree, Tree)
|
||||
end
|
||||
|
||||
-- Iterates over all sub-nodes, depth first
|
||||
-- node is where to start from, defaults to root
|
||||
-- depth is used for recursion but can be used to set the starting depth
|
||||
function Tree:forEach(callback, node, depth)
|
||||
depth = depth or 1
|
||||
for _, child in alphabeticalPairs(if node then node.children else self.ROOT.children) do
|
||||
callback(child, depth)
|
||||
if type(child.children) == "table" then
|
||||
self:forEach(callback, child, depth + 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Finds a node by id, depth first
|
||||
-- searchNode is the node to start the search within, defaults to root
|
||||
function Tree:getNode(id, searchNode)
|
||||
if self.idToNode[id] then
|
||||
return self.idToNode[id]
|
||||
end
|
||||
|
||||
local searchChildren = (searchNode or self.ROOT).children
|
||||
for nodeId, node in searchChildren do
|
||||
if nodeId == id then
|
||||
self.idToNode[id] = node
|
||||
return node
|
||||
end
|
||||
local descendant = self:getNode(id, node)
|
||||
if descendant then
|
||||
return descendant
|
||||
end
|
||||
end
|
||||
|
||||
return nil
|
||||
end
|
||||
|
||||
function Tree:doesNodeExist(id)
|
||||
return self.idToNode[id] ~= nil
|
||||
end
|
||||
|
||||
-- Adds a node to the tree as a child of the node with id == parent
|
||||
-- If parent is nil, it defaults to root
|
||||
-- props must contain id, and cannot contain children or parentId
|
||||
-- other than those three, it can hold anything
|
||||
function Tree:addNode(parent, props)
|
||||
assert(props.id, "props must contain id")
|
||||
|
||||
parent = parent or "ROOT"
|
||||
|
||||
if self:doesNodeExist(props.id) then
|
||||
-- Update existing node
|
||||
local node = self:getNode(props.id)
|
||||
for k, v in props do
|
||||
node[k] = v
|
||||
end
|
||||
return node
|
||||
end
|
||||
|
||||
local node = table.clone(props)
|
||||
node.children = {}
|
||||
node.parentId = parent
|
||||
|
||||
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
|
||||
|
||||
-- Given a list of ancestor ids in descending order, builds the nodes for them
|
||||
-- using the patch and instanceMap info
|
||||
function Tree:buildAncestryNodes(previousId: string?, ancestryIds: { string }, patch, instanceMap)
|
||||
-- Build nodes for ancestry by going up the tree
|
||||
previousId = previousId or "ROOT"
|
||||
|
||||
for _, ancestorId in ancestryIds 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,
|
||||
instance = if typeof(value) == "Instance" then value else nil,
|
||||
})
|
||||
previousId = ancestorId
|
||||
end
|
||||
end
|
||||
|
||||
local PatchTree = {}
|
||||
|
||||
-- Builds a new tree from a patch and instanceMap
|
||||
-- uses changeListHeaders in node.changeList
|
||||
function PatchTree.build(patch, instanceMap, changeListHeaders)
|
||||
local tree = Tree.new()
|
||||
|
||||
local knownAncestors = {}
|
||||
|
||||
for _, change in patch.updated do
|
||||
local instance = instanceMap.fromIds[change.id]
|
||||
if not instance then
|
||||
continue
|
||||
end
|
||||
|
||||
-- Gather ancestors from existing DOM
|
||||
local ancestryIds = {}
|
||||
local parentObject = instance.Parent
|
||||
local parentId = instanceMap.fromInstances[parentObject]
|
||||
local previousId = nil
|
||||
while parentObject do
|
||||
if knownAncestors[parentId] then
|
||||
-- We've already added this ancestor
|
||||
previousId = parentId
|
||||
break
|
||||
end
|
||||
|
||||
table.insert(ancestryIds, 1, parentId)
|
||||
knownAncestors[parentId] = true
|
||||
parentObject = parentObject.Parent
|
||||
parentId = instanceMap.fromInstances[parentObject]
|
||||
end
|
||||
|
||||
tree:buildAncestryNodes(previousId, ancestryIds, 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?, metadata: any?)
|
||||
i += 1
|
||||
hintBuffer[i] = prop
|
||||
changeList[i] = { prop, current, incoming, metadata }
|
||||
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 select(2, 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, changeListHeaders)
|
||||
end
|
||||
|
||||
-- Add this node to tree
|
||||
tree:addNode(instanceMap.fromInstances[instance.Parent], {
|
||||
id = change.id,
|
||||
patchType = "Edit",
|
||||
className = instance.ClassName,
|
||||
name = instance.Name,
|
||||
instance = instance,
|
||||
hint = hint,
|
||||
changeList = changeList,
|
||||
})
|
||||
end
|
||||
|
||||
for _, idOrInstance in patch.removed do
|
||||
local instance = if Types.RbxId(idOrInstance) then instanceMap.fromIds[idOrInstance] else idOrInstance
|
||||
if not instance then
|
||||
-- If we're viewing a past patch, the instance is already removed
|
||||
-- and we therefore cannot get the tree for it anymore
|
||||
continue
|
||||
end
|
||||
|
||||
-- Gather ancestors from existing DOM
|
||||
-- (note that they may have no ID if they're being removed as unknown)
|
||||
local ancestryIds = {}
|
||||
local parentObject = instance.Parent
|
||||
local parentId = instanceMap.fromInstances[parentObject] or HttpService:GenerateGUID(false)
|
||||
local previousId = nil
|
||||
while parentObject do
|
||||
if knownAncestors[parentId] then
|
||||
-- We've already added this ancestor
|
||||
previousId = parentId
|
||||
break
|
||||
end
|
||||
|
||||
instanceMap:insert(parentId, parentObject) -- This ensures we can find the parent later
|
||||
table.insert(ancestryIds, 1, parentId)
|
||||
knownAncestors[parentId] = true
|
||||
parentObject = parentObject.Parent
|
||||
parentId = instanceMap.fromInstances[parentObject] or HttpService:GenerateGUID(false)
|
||||
end
|
||||
|
||||
tree:buildAncestryNodes(previousId, ancestryIds, 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,
|
||||
instance = instance,
|
||||
})
|
||||
end
|
||||
|
||||
for id, change in patch.added do
|
||||
-- Gather ancestors from existing DOM or future additions
|
||||
local ancestryIds = {}
|
||||
local parentId = change.Parent
|
||||
local parentData = patch.added[parentId]
|
||||
local parentObject = instanceMap.fromIds[parentId]
|
||||
local previousId = nil
|
||||
while parentId do
|
||||
if knownAncestors[parentId] then
|
||||
-- We've already added this ancestor
|
||||
previousId = parentId
|
||||
break
|
||||
end
|
||||
|
||||
table.insert(ancestryIds, 1, parentId)
|
||||
knownAncestors[parentId] = true
|
||||
parentId = nil
|
||||
|
||||
if parentData then
|
||||
-- object is parented to an instance that does not exist yet
|
||||
parentId = parentData.Parent
|
||||
parentData = patch.added[parentId]
|
||||
parentObject = instanceMap.fromIds[parentId]
|
||||
elseif parentObject then
|
||||
-- object is parented to an instance that exists
|
||||
parentObject = parentObject.Parent
|
||||
parentId = instanceMap.fromInstances[parentObject]
|
||||
parentData = patch.added[parentId]
|
||||
end
|
||||
end
|
||||
|
||||
tree:buildAncestryNodes(previousId, ancestryIds, 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", select(2, 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, changeListHeaders)
|
||||
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,
|
||||
instance = instanceMap.fromIds[id],
|
||||
})
|
||||
end
|
||||
|
||||
return tree
|
||||
end
|
||||
|
||||
-- Creates a deep copy of a tree for immutability purposes in Roact
|
||||
function PatchTree.clone(tree)
|
||||
if not tree then
|
||||
return
|
||||
end
|
||||
|
||||
local newTree = Tree.new()
|
||||
tree:forEach(function(node)
|
||||
newTree:addNode(node.parentId, table.clone(node))
|
||||
end)
|
||||
|
||||
return newTree
|
||||
end
|
||||
|
||||
-- Updates the metadata of a tree with the unapplied patch and currently existing instances
|
||||
-- Builds a new tree from the data if one isn't provided
|
||||
-- Always returns a new tree for immutability purposes in Roact
|
||||
function PatchTree.updateMetadata(tree, patch, instanceMap, unappliedPatch)
|
||||
if tree then
|
||||
tree = PatchTree.clone(tree)
|
||||
else
|
||||
tree = PatchTree.build(patch, instanceMap)
|
||||
end
|
||||
|
||||
-- Update isWarning metadata
|
||||
for _, failedChange in unappliedPatch.updated do
|
||||
local node = tree:getNode(failedChange.id)
|
||||
if node then
|
||||
node.isWarning = true
|
||||
Log.trace("Marked node as warning: {} {}", node.id, node.name)
|
||||
|
||||
if node.changeList then
|
||||
for _, change in node.changeList do
|
||||
if failedChange.changedProperties[change[1]] then
|
||||
Log.trace(" Marked property as warning: {}", change[1])
|
||||
if change[4] == nil then
|
||||
change[4] = {}
|
||||
end
|
||||
change[4].isWarning = true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- Update if instances exist
|
||||
tree:forEach(function(node)
|
||||
if node.instance then
|
||||
if node.instance.Parent == nil and node.instance ~= game then
|
||||
-- This instance has been removed
|
||||
Log.trace("Removed instance from node: {} {}", node.id, node.name)
|
||||
node.instance = nil
|
||||
end
|
||||
else
|
||||
-- This instance may have been added
|
||||
node.instance = instanceMap.fromIds[node.id]
|
||||
if node.instance then
|
||||
Log.trace("Added instance to node: {} {}", node.id, node.name)
|
||||
end
|
||||
end
|
||||
end)
|
||||
|
||||
return tree
|
||||
end
|
||||
|
||||
return PatchTree
|
||||
@@ -5,6 +5,8 @@
|
||||
Patches can come from the server or be generated by the client.
|
||||
]]
|
||||
|
||||
local ChangeHistoryService = game:GetService("ChangeHistoryService")
|
||||
|
||||
local Packages = script.Parent.Parent.Parent.Packages
|
||||
local Log = require(Packages.Log)
|
||||
|
||||
@@ -17,6 +19,8 @@ local reify = require(script.Parent.reify)
|
||||
local setProperty = require(script.Parent.setProperty)
|
||||
|
||||
local function applyPatch(instanceMap, patch)
|
||||
local patchTimestamp = DateTime.now():FormatLocalTime("LTS", "en-us")
|
||||
|
||||
-- Tracks any portions of the patch that could not be applied to the DOM.
|
||||
local unappliedPatch = PatchSet.newEmpty()
|
||||
|
||||
@@ -199,6 +203,8 @@ local function applyPatch(instanceMap, patch)
|
||||
end
|
||||
end
|
||||
|
||||
ChangeHistoryService:SetWaypoint("Rojo: Patch " .. patchTimestamp)
|
||||
|
||||
return unappliedPatch
|
||||
end
|
||||
|
||||
|
||||
@@ -83,11 +83,11 @@ return function()
|
||||
ClassName = "Model",
|
||||
Name = "Child",
|
||||
Parent = "ROOT",
|
||||
Children = {"GRANDCHILD"},
|
||||
Children = { "GRANDCHILD" },
|
||||
Properties = {},
|
||||
}
|
||||
|
||||
patch.added["GRANDCHILD"] = {
|
||||
patch.added["GRANDCHILD"] = {
|
||||
Id = "GRANDCHILD",
|
||||
ClassName = "Part",
|
||||
Name = "Grandchild",
|
||||
@@ -193,4 +193,4 @@ return function()
|
||||
assert(newChild ~= nil, "expected child to be present")
|
||||
assert(newChild == child, "expected child to be preserved")
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -30,10 +30,11 @@ local function decodeValue(encodedValue, instanceMap)
|
||||
local ok, decodedValue = RbxDom.EncodedValue.decode(encodedValue)
|
||||
|
||||
if not ok then
|
||||
return false, Error.new(Error.CannotDecodeValue, {
|
||||
encodedValue = encodedValue,
|
||||
innerError = decodedValue,
|
||||
})
|
||||
return false,
|
||||
Error.new(Error.CannotDecodeValue, {
|
||||
encodedValue = encodedValue,
|
||||
innerError = decodedValue,
|
||||
})
|
||||
end
|
||||
|
||||
return true, decodedValue
|
||||
|
||||
@@ -15,6 +15,87 @@ local function isEmpty(table)
|
||||
return next(table) == nil
|
||||
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)
|
||||
if virtualInstance.Metadata ~= nil then
|
||||
return not virtualInstance.Metadata.ignoreUnknownInstances
|
||||
@@ -73,7 +154,14 @@ local function diff(instanceMap, virtualInstances, rootId)
|
||||
local ok, decodedValue = decodeValue(virtualValue, instanceMap)
|
||||
|
||||
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
|
||||
end
|
||||
else
|
||||
|
||||
@@ -264,7 +264,7 @@ return function()
|
||||
ClassName = "Folder",
|
||||
Name = "Folder",
|
||||
Properties = {},
|
||||
Children = {"CHILD"},
|
||||
Children = { "CHILD" },
|
||||
},
|
||||
|
||||
CHILD = {
|
||||
|
||||
@@ -14,17 +14,19 @@ local function getProperty(instance, propertyName)
|
||||
-- A good example of a property like this is `Model.ModelInPrimary`, which
|
||||
-- is serialized but not reflected to Lua.
|
||||
if descriptor == nil then
|
||||
return false, Error.new(Error.UnknownProperty, {
|
||||
className = instance.ClassName,
|
||||
propertyName = propertyName,
|
||||
})
|
||||
return false,
|
||||
Error.new(Error.UnknownProperty, {
|
||||
className = instance.ClassName,
|
||||
propertyName = propertyName,
|
||||
})
|
||||
end
|
||||
|
||||
if descriptor.scriptability == "None" or descriptor.scriptability == "Write" then
|
||||
return false, Error.new(Error.UnreadableProperty, {
|
||||
className = instance.ClassName,
|
||||
propertyName = propertyName,
|
||||
})
|
||||
return false,
|
||||
Error.new(Error.UnreadableProperty, {
|
||||
className = instance.ClassName,
|
||||
propertyName = propertyName,
|
||||
})
|
||||
end
|
||||
|
||||
local success, valueOrErr = descriptor:read(instance)
|
||||
@@ -35,23 +37,26 @@ local function getProperty(instance, propertyName)
|
||||
-- If we don't have permission to read a property, we can chalk that up
|
||||
-- to our database being out of date and the engine being right.
|
||||
if err.kind == RbxDom.Error.Kind.Roblox and err.extra:find("lacking permission") then
|
||||
return false, Error.new(Error.LackingPropertyPermissions, {
|
||||
className = instance.ClassName,
|
||||
propertyName = propertyName,
|
||||
})
|
||||
return false,
|
||||
Error.new(Error.LackingPropertyPermissions, {
|
||||
className = instance.ClassName,
|
||||
propertyName = propertyName,
|
||||
})
|
||||
end
|
||||
|
||||
if err.kind == RbxDom.Error.Kind.Roblox and err.extra:find("is not a valid member of") then
|
||||
return false, Error.new(Error.UnknownProperty, {
|
||||
return false,
|
||||
Error.new(Error.UnknownProperty, {
|
||||
className = instance.ClassName,
|
||||
propertyName = propertyName,
|
||||
})
|
||||
end
|
||||
|
||||
return false,
|
||||
Error.new(Error.OtherPropertyError, {
|
||||
className = instance.ClassName,
|
||||
propertyName = propertyName,
|
||||
})
|
||||
end
|
||||
|
||||
return false, Error.new(Error.OtherPropertyError, {
|
||||
className = instance.ClassName,
|
||||
propertyName = propertyName,
|
||||
})
|
||||
end
|
||||
|
||||
return true, valueOrErr
|
||||
|
||||
@@ -47,4 +47,4 @@ local function hydrate(instanceMap, virtualInstances, rootId, rootInstance)
|
||||
end
|
||||
end
|
||||
|
||||
return hydrate
|
||||
return hydrate
|
||||
|
||||
@@ -91,7 +91,7 @@ return function()
|
||||
ClassName = "Folder",
|
||||
Name = "Root",
|
||||
Properties = {},
|
||||
Children = {"CHILD1", "CHILD2"},
|
||||
Children = { "CHILD1", "CHILD2" },
|
||||
},
|
||||
|
||||
CHILD1 = {
|
||||
@@ -126,4 +126,4 @@ return function()
|
||||
expect(knownInstances.fromIds["CHILD1"]).to.equal(child1)
|
||||
expect(knownInstances.fromIds["CHILD2"]).to.equal(child2)
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
and mutating the Roblox DOM.
|
||||
]]
|
||||
|
||||
local Packages = script.Parent.Parent.Packages
|
||||
local Log = require(Packages.Log)
|
||||
|
||||
local applyPatch = require(script.applyPatch)
|
||||
local hydrate = require(script.hydrate)
|
||||
local diff = require(script.diff)
|
||||
@@ -14,13 +17,63 @@ function Reconciler.new(instanceMap)
|
||||
local self = {
|
||||
-- Tracks all of the instances known by the reconciler by ID.
|
||||
__instanceMap = instanceMap,
|
||||
__precommitCallbacks = {},
|
||||
__postcommitCallbacks = {},
|
||||
}
|
||||
|
||||
return setmetatable(self, Reconciler)
|
||||
end
|
||||
|
||||
function Reconciler:hookPrecommit(callback: (patch: any, instanceMap: any) -> ()): () -> ()
|
||||
table.insert(self.__precommitCallbacks, callback)
|
||||
Log.trace("Added precommit callback: {}", callback)
|
||||
|
||||
return function()
|
||||
-- Remove the callback from the list
|
||||
for i, cb in self.__precommitCallbacks do
|
||||
if cb == callback then
|
||||
table.remove(self.__precommitCallbacks, i)
|
||||
Log.trace("Removed precommit callback: {}", callback)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Reconciler:hookPostcommit(callback: (patch: any, instanceMap: any, unappliedPatch: any) -> ()): () -> ()
|
||||
table.insert(self.__postcommitCallbacks, callback)
|
||||
Log.trace("Added postcommit callback: {}", callback)
|
||||
|
||||
return function()
|
||||
-- Remove the callback from the list
|
||||
for i, cb in self.__postcommitCallbacks do
|
||||
if cb == callback then
|
||||
table.remove(self.__postcommitCallbacks, i)
|
||||
Log.trace("Removed postcommit callback: {}", callback)
|
||||
break
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Reconciler:applyPatch(patch)
|
||||
return applyPatch(self.__instanceMap, patch)
|
||||
for _, callback in self.__precommitCallbacks do
|
||||
local success, err = pcall(callback, patch, self.__instanceMap)
|
||||
if not success then
|
||||
Log.warn("Precommit hook errored: {}", err)
|
||||
end
|
||||
end
|
||||
|
||||
local unappliedPatch = applyPatch(self.__instanceMap, patch)
|
||||
|
||||
for _, callback in self.__postcommitCallbacks do
|
||||
local success, err = pcall(callback, patch, self.__instanceMap, unappliedPatch)
|
||||
if not success then
|
||||
Log.warn("Postcommit hook errored: {}", err)
|
||||
end
|
||||
end
|
||||
|
||||
return unappliedPatch
|
||||
end
|
||||
|
||||
function Reconciler:hydrate(virtualInstances, rootId, rootInstance)
|
||||
@@ -31,4 +84,4 @@ function Reconciler:diff(virtualInstances, rootId)
|
||||
return diff(self.__instanceMap, virtualInstances, rootId)
|
||||
end
|
||||
|
||||
return Reconciler
|
||||
return Reconciler
|
||||
|
||||
@@ -80,7 +80,7 @@ return function()
|
||||
ClassName = "Folder",
|
||||
Name = "Parent",
|
||||
Properties = {},
|
||||
Children = {"CHILD"},
|
||||
Children = { "CHILD" },
|
||||
},
|
||||
|
||||
CHILD = {
|
||||
@@ -112,7 +112,7 @@ return function()
|
||||
ClassName = "Folder",
|
||||
Name = "Parent",
|
||||
Properties = {},
|
||||
Children = {"CHILD"},
|
||||
Children = { "CHILD" },
|
||||
},
|
||||
|
||||
CHILD = {
|
||||
@@ -147,7 +147,7 @@ return function()
|
||||
Properties = {
|
||||
Value = {
|
||||
Type = "Vector3",
|
||||
Value = {1, 2, 3},
|
||||
Value = { 1, 2, 3 },
|
||||
},
|
||||
},
|
||||
Children = {},
|
||||
@@ -182,7 +182,7 @@ return function()
|
||||
ClassName = "Folder",
|
||||
Name = "Root",
|
||||
Properties = {},
|
||||
Children = {"CHILD"},
|
||||
Children = { "CHILD" },
|
||||
},
|
||||
|
||||
CHILD = {
|
||||
@@ -247,7 +247,7 @@ return function()
|
||||
ClassName = "Folder",
|
||||
Name = "Root",
|
||||
Properties = {},
|
||||
Children = {"CHILD_A", "CHILD_B"},
|
||||
Children = { "CHILD_A", "CHILD_B" },
|
||||
},
|
||||
|
||||
CHILD_A = {
|
||||
@@ -297,7 +297,7 @@ return function()
|
||||
Ref = "CHILD",
|
||||
},
|
||||
},
|
||||
Children = {"CHILD"},
|
||||
Children = { "CHILD" },
|
||||
},
|
||||
|
||||
CHILD = {
|
||||
|
||||
@@ -21,26 +21,29 @@ local function setProperty(instance, propertyName, value)
|
||||
end
|
||||
|
||||
if descriptor.scriptability == "None" or descriptor.scriptability == "Read" then
|
||||
return false, Error.new(Error.UnwritableProperty, {
|
||||
className = instance.ClassName,
|
||||
propertyName = propertyName,
|
||||
})
|
||||
return false,
|
||||
Error.new(Error.UnwritableProperty, {
|
||||
className = instance.ClassName,
|
||||
propertyName = propertyName,
|
||||
})
|
||||
end
|
||||
|
||||
local ok, err = descriptor:write(instance, value)
|
||||
|
||||
if not ok then
|
||||
if err.kind == RbxDom.Error.Kind.Roblox and err.extra:find("lacking permission") then
|
||||
return false, Error.new(Error.LackingPropertyPermissions, {
|
||||
return false,
|
||||
Error.new(Error.LackingPropertyPermissions, {
|
||||
className = instance.ClassName,
|
||||
propertyName = propertyName,
|
||||
})
|
||||
end
|
||||
|
||||
return false,
|
||||
Error.new(Error.OtherPropertyError, {
|
||||
className = instance.ClassName,
|
||||
propertyName = propertyName,
|
||||
})
|
||||
end
|
||||
|
||||
return false, Error.new(Error.OtherPropertyError, {
|
||||
className = instance.ClassName,
|
||||
propertyName = propertyName,
|
||||
})
|
||||
end
|
||||
|
||||
return true
|
||||
|
||||
@@ -5,8 +5,10 @@ local Packages = script.Parent.Parent.Packages
|
||||
local Log = require(Packages.Log)
|
||||
local Fmt = require(Packages.Fmt)
|
||||
local t = require(Packages.t)
|
||||
local Promise = require(Packages.Promise)
|
||||
|
||||
local ChangeBatcher = require(script.Parent.ChangeBatcher)
|
||||
local encodePatchUpdate = require(script.Parent.ChangeBatcher.encodePatchUpdate)
|
||||
local InstanceMap = require(script.Parent.InstanceMap)
|
||||
local PatchSet = require(script.Parent.PatchSet)
|
||||
local Reconciler = require(script.Parent.Reconciler)
|
||||
@@ -75,15 +77,13 @@ function ServeSession.new(options)
|
||||
|
||||
local connections = {}
|
||||
|
||||
local connection = StudioService
|
||||
:GetPropertyChangedSignal("ActiveScript")
|
||||
:Connect(function()
|
||||
local activeScript = StudioService.ActiveScript
|
||||
local connection = StudioService:GetPropertyChangedSignal("ActiveScript"):Connect(function()
|
||||
local activeScript = StudioService.ActiveScript
|
||||
|
||||
if activeScript ~= nil then
|
||||
self:__onActiveScriptChanged(activeScript)
|
||||
end
|
||||
end)
|
||||
if activeScript ~= nil then
|
||||
self:__onActiveScriptChanged(activeScript)
|
||||
end
|
||||
end)
|
||||
table.insert(connections, connection)
|
||||
|
||||
self = {
|
||||
@@ -95,7 +95,6 @@ function ServeSession.new(options)
|
||||
__instanceMap = instanceMap,
|
||||
__changeBatcher = changeBatcher,
|
||||
__statusChangedCallback = nil,
|
||||
__patchAppliedCallback = nil,
|
||||
__connections = connections,
|
||||
}
|
||||
|
||||
@@ -123,24 +122,31 @@ function ServeSession:onStatusChanged(callback)
|
||||
self.__statusChangedCallback = callback
|
||||
end
|
||||
|
||||
function ServeSession:onPatchApplied(callback)
|
||||
self.__patchAppliedCallback = callback
|
||||
function ServeSession:setConfirmCallback(callback)
|
||||
self.__userConfirmCallback = callback
|
||||
end
|
||||
|
||||
function ServeSession:hookPrecommit(callback)
|
||||
return self.__reconciler:hookPrecommit(callback)
|
||||
end
|
||||
|
||||
function ServeSession:hookPostcommit(callback)
|
||||
return self.__reconciler:hookPostcommit(callback)
|
||||
end
|
||||
|
||||
function ServeSession:start()
|
||||
self:__setStatus(Status.Connecting)
|
||||
|
||||
self.__apiContext:connect()
|
||||
self.__apiContext
|
||||
:connect()
|
||||
:andThen(function(serverInfo)
|
||||
self:__setStatus(Status.Connected, serverInfo.projectName)
|
||||
self:__applyGameAndPlaceId(serverInfo)
|
||||
|
||||
local rootInstanceId = serverInfo.rootInstanceId
|
||||
return self:__initialSync(serverInfo):andThen(function()
|
||||
self:__setStatus(Status.Connected, serverInfo.projectName)
|
||||
|
||||
return self:__initialSync(rootInstanceId)
|
||||
:andThen(function()
|
||||
return self:__mainSyncLoop()
|
||||
end)
|
||||
return self:__mainSyncLoop()
|
||||
end)
|
||||
end)
|
||||
:catch(function(err)
|
||||
if self.__status ~= Status.Disconnected then
|
||||
@@ -202,70 +208,128 @@ function ServeSession:__onActiveScriptChanged(activeScript)
|
||||
self.__apiContext:open(scriptId)
|
||||
end
|
||||
|
||||
function ServeSession:__initialSync(rootInstanceId)
|
||||
return self.__apiContext:read({ rootInstanceId })
|
||||
:andThen(function(readResponseBody)
|
||||
-- Tell the API Context that we're up-to-date with the version of
|
||||
-- the tree defined in this response.
|
||||
self.__apiContext:setMessageCursor(readResponseBody.messageCursor)
|
||||
function ServeSession:__initialSync(serverInfo)
|
||||
return self.__apiContext:read({ serverInfo.rootInstanceId }):andThen(function(readResponseBody)
|
||||
-- Tell the API Context that we're up-to-date with the version of
|
||||
-- the tree defined in this response.
|
||||
self.__apiContext:setMessageCursor(readResponseBody.messageCursor)
|
||||
|
||||
-- For any instances that line up with the Rojo server's view, start
|
||||
-- tracking them in the reconciler.
|
||||
Log.trace("Matching existing Roblox instances to Rojo IDs")
|
||||
self.__reconciler:hydrate(readResponseBody.instances, rootInstanceId, game)
|
||||
-- For any instances that line up with the Rojo server's view, start
|
||||
-- tracking them in the reconciler.
|
||||
Log.trace("Matching existing Roblox instances to Rojo IDs")
|
||||
self.__reconciler:hydrate(readResponseBody.instances, serverInfo.rootInstanceId, game)
|
||||
|
||||
-- Calculate the initial patch to apply to the DataModel to catch us
|
||||
-- up to what Rojo thinks the place should look like.
|
||||
Log.trace("Computing changes that plugin needs to make to catch up to server...")
|
||||
local success, catchUpPatch = self.__reconciler:diff(
|
||||
readResponseBody.instances,
|
||||
rootInstanceId,
|
||||
game
|
||||
)
|
||||
-- Calculate the initial patch to apply to the DataModel to catch us
|
||||
-- up to what Rojo thinks the place should look like.
|
||||
Log.trace("Computing changes that plugin needs to make to catch up to server...")
|
||||
local success, catchUpPatch =
|
||||
self.__reconciler:diff(readResponseBody.instances, serverInfo.rootInstanceId, game)
|
||||
|
||||
if not success then
|
||||
Log.error("Could not compute a diff to catch up to the Rojo server: {:#?}", catchUpPatch)
|
||||
if not success then
|
||||
Log.error("Could not compute a diff to catch up to the Rojo server: {:#?}", catchUpPatch)
|
||||
end
|
||||
|
||||
for _, update in catchUpPatch.updated do
|
||||
if update.id == self.__instanceMap.fromInstances[game] and update.changedClassName ~= nil then
|
||||
-- Non-place projects will try to update the classname of game from DataModel to
|
||||
-- something like Folder, ModuleScript, etc. This would fail, so we exit with a clear
|
||||
-- message instead of crashing.
|
||||
return Promise.reject(
|
||||
"Cannot sync a model as a place."
|
||||
.. "\nEnsure Rojo is serving a project file that has a DataModel at the root of its tree and try again."
|
||||
.. "\nSee project file docs: https://rojo.space/docs/v7/project-format/"
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
Log.trace("Computed hydration patch: {:#?}", debugPatch(catchUpPatch))
|
||||
|
||||
local userDecision = "Accept"
|
||||
if self.__userConfirmCallback ~= nil then
|
||||
userDecision = self.__userConfirmCallback(self.__instanceMap, catchUpPatch, serverInfo)
|
||||
end
|
||||
|
||||
if userDecision == "Abort" then
|
||||
return Promise.reject("Aborted Rojo sync operation")
|
||||
elseif userDecision == "Reject" and self.__twoWaySync then
|
||||
-- The user wants their studio DOM to write back to their Rojo DOM
|
||||
-- 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
|
||||
|
||||
Log.trace("Computed hydration patch: {:#?}", debugPatch(catchUpPatch))
|
||||
|
||||
-- TODO: Prompt user to notify them of this patch, since it's
|
||||
-- effectively a conflict between the Rojo server and the client. In
|
||||
-- the future, we'll ask which changes the user wants to keep.
|
||||
|
||||
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))
|
||||
Log.debug(
|
||||
"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
|
||||
|
||||
function ServeSession:__mainSyncLoop()
|
||||
return self.__apiContext:retrieveMessages()
|
||||
:andThen(function(messages)
|
||||
for _, message in ipairs(messages) do
|
||||
local unappliedPatch = self.__reconciler:applyPatch(message)
|
||||
return Promise.new(function(resolve, reject)
|
||||
while self.__status == Status.Connected do
|
||||
local success, result = self.__apiContext
|
||||
:retrieveMessages()
|
||||
:andThen(function(messages)
|
||||
if self.__status == Status.Disconnected then
|
||||
-- In the time it took to retrieve messages, we disconnected
|
||||
-- so we just resolve immediately without patching anything
|
||||
return
|
||||
end
|
||||
|
||||
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
|
||||
Log.trace("Serve session {} retrieved {} messages", tostring(self), #messages)
|
||||
|
||||
if self.__patchAppliedCallback then
|
||||
pcall(self.__patchAppliedCallback, message, unappliedPatch)
|
||||
end
|
||||
for _, message in messages do
|
||||
local unappliedPatch = self.__reconciler:applyPatch(message)
|
||||
|
||||
if not PatchSet.isEmpty(unappliedPatch) then
|
||||
Log.debug(
|
||||
"Could not apply all changes requested by the Rojo server:\n{}",
|
||||
PatchSet.humanSummary(self.__instanceMap, unappliedPatch)
|
||||
)
|
||||
end
|
||||
end
|
||||
end)
|
||||
:await()
|
||||
|
||||
if self.__status == Status.Disconnected then
|
||||
-- If we are no longer connected after applying, we stop silently
|
||||
-- without checking for errors as they are no longer relevant
|
||||
break
|
||||
elseif success == false then
|
||||
reject(result)
|
||||
end
|
||||
end
|
||||
|
||||
if self.__status ~= Status.Disconnected then
|
||||
return self:__mainSyncLoop()
|
||||
end
|
||||
end)
|
||||
-- We are no longer connected, so we resolve the promise
|
||||
resolve()
|
||||
end)
|
||||
end
|
||||
|
||||
function ServeSession:__stopInternal(err)
|
||||
|
||||
@@ -7,18 +7,26 @@ 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,
|
||||
syncReminder = true,
|
||||
confirmationBehavior = "Initial",
|
||||
largeChangesConfirmationThreshold = 5,
|
||||
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
|
||||
@@ -45,6 +53,9 @@ 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.
|
||||
@@ -76,4 +87,21 @@ function Settings:onChanged(name, callback)
|
||||
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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
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 RbxId = t.string
|
||||
@@ -56,17 +56,13 @@ local ApiSubscribeResponse = t.interface({
|
||||
})
|
||||
|
||||
local ApiError = t.interface({
|
||||
kind = t.union(
|
||||
t.literal("NotFound"),
|
||||
t.literal("BadRequest"),
|
||||
t.literal("InternalError")
|
||||
),
|
||||
kind = t.union(t.literal("NotFound"), t.literal("BadRequest"), t.literal("InternalError")),
|
||||
details = t.string,
|
||||
})
|
||||
|
||||
local function ifEnabled(innerCheck)
|
||||
return function(...)
|
||||
if DevSettings:shouldTypecheck() then
|
||||
if Settings:get("typecheckingEnabled") then
|
||||
return innerCheck(...)
|
||||
else
|
||||
return true
|
||||
|
||||
@@ -43,4 +43,4 @@ function Version.display(version)
|
||||
return output
|
||||
end
|
||||
|
||||
return Version
|
||||
return Version
|
||||
|
||||
@@ -2,27 +2,27 @@ return function()
|
||||
local Version = require(script.Parent.Version)
|
||||
|
||||
it("should compare equal versions", function()
|
||||
expect(Version.compare({1, 2, 3}, {1, 2, 3})).to.equal(0)
|
||||
expect(Version.compare({0, 4, 0}, {0, 4})).to.equal(0)
|
||||
expect(Version.compare({0, 0, 123}, {0, 0, 123})).to.equal(0)
|
||||
expect(Version.compare({26}, {26})).to.equal(0)
|
||||
expect(Version.compare({26, 42}, {26, 42})).to.equal(0)
|
||||
expect(Version.compare({1, 0, 0}, {1})).to.equal(0)
|
||||
expect(Version.compare({ 1, 2, 3 }, { 1, 2, 3 })).to.equal(0)
|
||||
expect(Version.compare({ 0, 4, 0 }, { 0, 4 })).to.equal(0)
|
||||
expect(Version.compare({ 0, 0, 123 }, { 0, 0, 123 })).to.equal(0)
|
||||
expect(Version.compare({ 26 }, { 26 })).to.equal(0)
|
||||
expect(Version.compare({ 26, 42 }, { 26, 42 })).to.equal(0)
|
||||
expect(Version.compare({ 1, 0, 0 }, { 1 })).to.equal(0)
|
||||
end)
|
||||
|
||||
it("should compare newer, older versions", function()
|
||||
expect(Version.compare({1}, {0})).to.equal(1)
|
||||
expect(Version.compare({1, 1}, {1, 0})).to.equal(1)
|
||||
expect(Version.compare({ 1 }, { 0 })).to.equal(1)
|
||||
expect(Version.compare({ 1, 1 }, { 1, 0 })).to.equal(1)
|
||||
end)
|
||||
|
||||
it("should compare different major versions", function()
|
||||
expect(Version.compare({1, 3, 2}, {2, 2, 1})).to.equal(-1)
|
||||
expect(Version.compare({1, 2}, {2, 1})).to.equal(-1)
|
||||
expect(Version.compare({1}, {2})).to.equal(-1)
|
||||
expect(Version.compare({ 1, 3, 2 }, { 2, 2, 1 })).to.equal(-1)
|
||||
expect(Version.compare({ 1, 2 }, { 2, 1 })).to.equal(-1)
|
||||
expect(Version.compare({ 1 }, { 2 })).to.equal(-1)
|
||||
end)
|
||||
|
||||
it("should compare different minor versions", function()
|
||||
expect(Version.compare({1, 2, 3}, {1, 3, 2})).to.equal(-1)
|
||||
expect(Version.compare({50, 1}, {50, 2})).to.equal(-1)
|
||||
expect(Version.compare({ 1, 2, 3 }, { 1, 3, 2 })).to.equal(-1)
|
||||
expect(Version.compare({ 50, 1 }, { 50, 2 })).to.equal(-1)
|
||||
end)
|
||||
end
|
||||
|
||||
@@ -50,4 +50,4 @@ local function createSignal()
|
||||
}
|
||||
end
|
||||
|
||||
return createSignal
|
||||
return createSignal
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user